klenod-lsp 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,428 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "language_server-protocol"
4
+
5
+ require "klenod/build/module_id"
6
+
7
+ require_relative "../diagnostics"
8
+ require_relative "../text"
9
+ require_relative "haml/ruby_regions"
10
+ require_relative "syntax"
11
+
12
+ module Klenod
13
+ module LSP
14
+ module Languages
15
+ # Everything shared by languages whose modules import through
16
+ # `import("...")` literals: finding the literal under the cursor or all
17
+ # literals in a document, resolving it, summarizing the target, and
18
+ # completing the path being typed.
19
+ module Imports
20
+ Interface = LanguageServer::Protocol::Interface
21
+ Constant = LanguageServer::Protocol::Constant
22
+
23
+ BINDING = /\A\s*(?<name>[A-Z][A-Za-z0-9_]*)\s*=\s*(?:lazy_)?import\(\s*(?<quote>["'])(?<specifier>[^"']*)\k<quote>/
24
+ PROP_TOKEN = /\$(?<name>[a-z]\w*|\*)/
25
+ SLOT_TOKEN = /\$children\[:(?<name>\w+)\]/
26
+
27
+ # What a Haml component reads through the `$name` mapping: the prop
28
+ # names, whether it takes every prop with `$*`, and the named slots it
29
+ # renders through `$children[:name]`.
30
+ ComponentProps = Data.define(:names, :splat, :slots)
31
+ SKIPPED_FILE = /\A\.|\.test\.rb\z/
32
+
33
+ # Something in a document that names another module. `resolve_kind`
34
+ # is the dependency kind Klenod resolves it with.
35
+ Target = Data.define(:kind, :name, :specifier, :span, :resolve_kind) do
36
+ def initialize(kind:, name:, specifier:, span:, resolve_kind: :haml_import) = super
37
+ end
38
+
39
+ module_function
40
+
41
+ def target_at(line_text, position, syntax: Syntax::Ruby)
42
+ each_target(line_text, position.line, syntax: syntax) do |target|
43
+ return target if target.span.include?(position)
44
+ end
45
+
46
+ (syntax == Syntax::Ruby) ? binding_target_at(line_text, position) : nil
47
+ end
48
+
49
+ # The constant a module is bound to, as in `Card = import("./Card")`.
50
+ def binding_target_at(line_text, position)
51
+ Text.each_match(line_text, position.line, BINDING, group: :name) do |match, span|
52
+ return Target.new(kind: :binding, name: match[:name], specifier: match[:specifier], span: span) if span.include?(position)
53
+ end
54
+
55
+ nil
56
+ end
57
+
58
+ # Constant names bound to imports in a document, keyed by name.
59
+ def bindings(lines)
60
+ lines.each_with_object({}) do |line_text, bindings|
61
+ match = BINDING.match(line_text)
62
+ bindings[match[:name]] ||= match[:specifier] if match
63
+ end
64
+ end
65
+
66
+ # Import literals on one line in the given syntax.
67
+ def each_target(line_text, line_index, syntax: Syntax::Ruby)
68
+ syntax.each_literal(line_text, line_index) do |literal|
69
+ yield Target.new(kind: :import, name: literal.specifier, specifier: literal.specifier, span: literal.span, resolve_kind: literal.kind)
70
+ end
71
+ end
72
+
73
+ def resolve(target, analysis, workspace)
74
+ analysis.resolved_module_id_for(target.specifier) || workspace.resolve(target.specifier, importer_id: analysis.module_id, kind: target.resolve_kind)
75
+ end
76
+
77
+ def location(module_id, workspace)
78
+ uri = workspace.uri_for_module_id(module_id)
79
+ uri && Interface::Location.new(uri: uri, range: Text.zero_range)
80
+ end
81
+
82
+ # Markdown summary: module id, path, and the props a Haml component
83
+ # reads through the configured `$name` mapping.
84
+ def hover(target, module_id, workspace)
85
+ path = workspace.path_for_module_id(module_id)
86
+ lines = ["**#{target.name}** · `#{module_id}`"]
87
+ lines << "`#{path.delete_prefix("#{workspace.source_dir}/")}`" if path
88
+
89
+ props = props_for(path, workspace)
90
+ if props
91
+ names = props.names + (props.splat ? ["*"] : [])
92
+ lines << "Props: #{names.map { |name| "`#{name}`" }.join(", ")}" unless names.empty?
93
+ lines << "Slots: #{props.slots.map { |slot| "`#{slot}`" }.join(", ")}" unless props.slots.empty?
94
+ end
95
+
96
+ Interface::Hover.new(
97
+ contents: Interface::MarkupContent.new(kind: Constant::MarkupKind::MARKDOWN, value: lines.join("\n\n")),
98
+ range: target.span.to_range
99
+ )
100
+ end
101
+
102
+ # Only lowercase `$name` globals are rewritten to prop reads, and only
103
+ # when the Haml plugin maps global variables at all.
104
+ def props_for(path, workspace)
105
+ return nil unless path && File.extname(path) == ".haml"
106
+
107
+ component_props(File.read(path), workspace)
108
+ rescue SystemCallError
109
+ nil
110
+ end
111
+
112
+ def component_props(source, workspace)
113
+ return nil unless workspace.haml_variables[:global]
114
+
115
+ lines = source.lines(chomp: true)
116
+ tokens = []
117
+ slots = []
118
+ Haml::RubyRegions.each(source, lines) do |_line_index, _start_character, ruby_source|
119
+ tokens.concat(ruby_source.scan(PROP_TOKEN).flatten)
120
+ slots.concat(ruby_source.scan(SLOT_TOKEN).flatten)
121
+ end
122
+ ComponentProps.new(
123
+ names: tokens.reject { |name| name == "*" }.uniq.sort,
124
+ splat: tokens.include?("*"),
125
+ slots: slots.uniq.sort
126
+ )
127
+ end
128
+
129
+ # Completion items for the path being typed inside an import literal,
130
+ # or nil when the line prefix is not inside one.
131
+ def completion_items(prefix, position, analysis, workspace, syntax: Syntax::Ruby)
132
+ partial = syntax.prefix_partial(prefix)
133
+ return nil unless partial
134
+ return nil if partial.match?(Klenod::Build::ModuleId::SCHEME_PATTERN)
135
+
136
+ directory = completion_directory(partial, analysis, workspace)
137
+ return nil unless directory
138
+
139
+ basename_partial = partial.split("/", -1).last.to_s
140
+ range = replacement_range(position, basename_partial)
141
+
142
+ entries(directory).filter_map do |name, folder|
143
+ next unless name.start_with?(basename_partial)
144
+
145
+ label = folder ? "#{name}/" : name
146
+ Interface::CompletionItem.new(
147
+ label: label,
148
+ kind: folder ? Constant::CompletionItemKind::FOLDER : Constant::CompletionItemKind::FILE,
149
+ sort_text: "#{folder ? 0 : 1}#{name}",
150
+ text_edit: Interface::TextEdit.new(range: range, new_text: label)
151
+ )
152
+ end
153
+ end
154
+
155
+ # Leading-slash specifiers start at the source root; everything else
156
+ # starts next to the importing module, and both must stay inside it.
157
+ def completion_directory(partial, analysis, workspace)
158
+ directory_part = partial.include?("/") ? partial[0..partial.rindex("/")] : ""
159
+ base =
160
+ if partial.start_with?("/")
161
+ directory_part = directory_part.delete_prefix("/")
162
+ workspace.source_dir
163
+ else
164
+ importer_path = workspace.path_for_module_id(analysis.module_id)
165
+ importer_path && File.dirname(importer_path)
166
+ end
167
+ return nil unless base
168
+
169
+ directory = File.expand_path(directory_part, base)
170
+ return nil unless directory == workspace.source_dir || directory.start_with?("#{workspace.source_dir}/")
171
+ return nil unless File.directory?(directory)
172
+
173
+ directory
174
+ end
175
+
176
+ def entries(directory)
177
+ Dir.children(directory).sort.filter_map do |name|
178
+ next if name.match?(SKIPPED_FILE)
179
+
180
+ [name, File.directory?(File.join(directory, name))]
181
+ end
182
+ end
183
+
184
+ STRING_LITERAL = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/
185
+
186
+ def constant_pattern(name)
187
+ /(?<![\w:@$])(?<name>#{Regexp.escape(name)})(?!\w)/
188
+ end
189
+
190
+ # Whole-word occurrences of a constant in Ruby text, ignoring the
191
+ # contents of string literals.
192
+ def constant_spans(text, line_index, name)
193
+ blanked = text.gsub(STRING_LITERAL) { |literal| " " * literal.length }
194
+ spans = []
195
+ Text.each_match(blanked, line_index, constant_pattern(name), group: :name) { |_match, span| spans << span }
196
+ spans
197
+ end
198
+
199
+ def replacement_range(position, partial)
200
+ Text::Span.new(position.line, position.character - partial.length, position.character).to_range
201
+ end
202
+ end
203
+
204
+ # Request handlers shared by languages that navigate through imports.
205
+ # Including classes define `target_at(analysis, position)`.
206
+ module ImportNavigation
207
+ # How this language spells imports. Ruby-shaped unless overridden.
208
+ def syntax
209
+ Syntax::Ruby
210
+ end
211
+
212
+ def definition(analysis, position, workspace, _index = nil)
213
+ target = target_at(analysis, position)
214
+ module_id = target && Imports.resolve(target, analysis, workspace)
215
+ module_id && Imports.location(module_id, workspace)
216
+ end
217
+
218
+ def hover(analysis, position, workspace, _index = nil)
219
+ target = target_at(analysis, position)
220
+ return route_hover(analysis, position, workspace) unless target
221
+
222
+ module_id = Imports.resolve(target, analysis, workspace)
223
+ module_id && Imports.hover(target, module_id, workspace)
224
+ end
225
+
226
+ # Hovering the first line of a route module describes the route it
227
+ # serves, the same place its lenses appear.
228
+ def route_hover(analysis, position, workspace)
229
+ return nil unless position.line.zero?
230
+
231
+ markdown = workspace.routes.hover_markdown(analysis.module_id)
232
+ return nil unless markdown
233
+
234
+ Imports::Interface::Hover.new(
235
+ contents: Imports::Interface::MarkupContent.new(kind: Imports::Constant::MarkupKind::MARKDOWN, value: markdown),
236
+ range: Text.line_span(analysis.lines, 0).to_range
237
+ )
238
+ end
239
+
240
+ # References to the module under the cursor, or to the document's own
241
+ # module when the cursor is on nothing in particular.
242
+ def references(analysis, position, workspace, index, include_declaration: false)
243
+ target = target_at(analysis, position)
244
+ module_id = target ? Imports.resolve(target, analysis, workspace) : analysis.module_id
245
+ return [] unless module_id
246
+
247
+ index.ensure_collected(module_id) if workspace.path_for_module_id(module_id)
248
+ reference_locations(module_id, index, workspace, include_declaration: include_declaration)
249
+ end
250
+
251
+ # One lens on the first line with the number of places that import
252
+ # or render this module. Clients that know VS Code's command show
253
+ # the list on click; others show the count.
254
+ def code_lenses(analysis, workspace, index)
255
+ locations = reference_locations(analysis.module_id, index, workspace)
256
+ uri = workspace.uri_for_module_id(analysis.module_id)
257
+ return [] unless uri
258
+
259
+ title =
260
+ case locations.length
261
+ when 0 then "No references"
262
+ when 1 then "1 reference"
263
+ else "#{locations.length} references"
264
+ end
265
+ command = Imports::Interface::Command.new(
266
+ title: title,
267
+ command: "editor.action.showReferences",
268
+ arguments: [uri, Imports::Interface::Position.new(line: 0, character: 0), locations]
269
+ )
270
+
271
+ range = Text.line_span(analysis.lines, 0).to_range
272
+ route_lenses = workspace.routes.descriptions(analysis.module_id).map do |description|
273
+ Imports::Interface::CodeLens.new(range: range, command: Imports::Interface::Command.new(title: description, command: ""))
274
+ end
275
+
276
+ [*route_lenses, Imports::Interface::CodeLens.new(range: range, command: command)]
277
+ end
278
+
279
+ CONSTANT_NAME = /\A[A-Z][A-Za-z0-9_]*\z/
280
+
281
+ class InvalidRename < StandardError; end
282
+
283
+ # The constant a module is bound to can be renamed within its file:
284
+ # the cursor must be on the binding or on a component tag.
285
+ def prepare_rename(analysis, position)
286
+ target = rename_target(analysis, position)
287
+ return nil unless target
288
+
289
+ {range: target.span.to_range, placeholder: target.name.split("::").first}
290
+ end
291
+
292
+ def rename(analysis, position, new_name, workspace)
293
+ raise InvalidRename, "#{new_name.inspect} is not a constant name" unless new_name.match?(CONSTANT_NAME)
294
+
295
+ target = rename_target(analysis, position)
296
+ uri = target && workspace.uri_for_module_id(analysis.module_id)
297
+ return nil unless uri
298
+
299
+ name = target.name.split("::").first
300
+ edits = rename_spans(analysis, name).map { |span| Imports::Interface::TextEdit.new(range: span.to_range, new_text: new_name) }
301
+ return nil if edits.empty?
302
+
303
+ Imports::Interface::WorkspaceEdit.new(changes: {uri => edits})
304
+ end
305
+
306
+ # Whole-word occurrences of the constant outside comments. Languages
307
+ # with non-Ruby text override this to stay inside Ruby contexts.
308
+ def rename_spans(analysis, name)
309
+ analysis.lines.each_with_index.flat_map do |line_text, index|
310
+ line_text.match?(/\A\s*#/) ? [] : Imports.constant_spans(line_text, index, name)
311
+ end
312
+ end
313
+
314
+ def rename_target(analysis, position)
315
+ target = target_at(analysis, position)
316
+ target if target && %i[binding component].include?(target.kind)
317
+ end
318
+
319
+ # Every import literal that resolves to a file becomes a link.
320
+ def document_links(analysis, workspace)
321
+ links = []
322
+
323
+ analysis.lines.each_with_index do |line_text, index|
324
+ Imports.each_target(line_text, index, syntax: syntax) do |target|
325
+ module_id = Imports.resolve(target, analysis, workspace)
326
+ uri = module_id && workspace.uri_for_module_id(module_id)
327
+ links << Imports::Interface::DocumentLink.new(range: target.span.to_range, target: uri) if uri
328
+ end
329
+ end
330
+
331
+ links
332
+ end
333
+
334
+ # Every place the graph knows imports the target: import literals in
335
+ # its dependents and, in Haml importers, the `%Component` tags bound to
336
+ # it. `include_declaration` adds the target file itself.
337
+ def reference_locations(target_module_id, index, workspace, include_declaration: false)
338
+ locations = []
339
+
340
+ index.dependents(target_module_id).each do |importer_id|
341
+ record = index.record(importer_id)
342
+ uri = record && workspace.uri_for_module_id(importer_id)
343
+ next unless uri
344
+
345
+ lines = record.source.lines(chomp: true)
346
+ importer_syntax = Languages.syntax_for(importer_id.extname)
347
+ specifiers = record.resolved_dependencies.select { |resolved| resolved.module_id == target_module_id }.map { |resolved| resolved.dependency.specifier.to_s }.uniq
348
+ specifiers.each do |specifier|
349
+ Diagnostics.literal_spans(specifier, lines, syntax: importer_syntax).each do |span|
350
+ locations << Imports::Interface::Location.new(uri: uri, range: span.to_range)
351
+ end
352
+ end
353
+ usage_spans(importer_id, lines, specifiers).each do |span|
354
+ locations << Imports::Interface::Location.new(uri: uri, range: span.to_range)
355
+ end
356
+ end
357
+
358
+ if include_declaration && (uri = workspace.uri_for_module_id(target_module_id))
359
+ locations << Imports::Interface::Location.new(uri: uri, range: Text.zero_range)
360
+ end
361
+
362
+ locations.uniq { |location| [location.uri, location.range.start.line, location.range.start.character] }
363
+ .sort_by { |location| [location.uri, location.range.start.line, location.range.start.character] }
364
+ end
365
+
366
+ # `%Name` tags in a Haml importer whose binding refers to the target.
367
+ def usage_spans(importer_id, lines, specifiers)
368
+ return [] unless importer_id.extname == ".haml"
369
+
370
+ names = Imports.bindings(lines).select { |_name, specifier| specifiers.include?(specifier) }.keys
371
+ return [] if names.empty?
372
+
373
+ spans = []
374
+ lines.each_with_index do |line_text, index|
375
+ Text.each_match(line_text, index, Languages::Haml::COMPONENT_TAG, group: :name) do |match, span|
376
+ spans << span if names.include?(match[:name].split("::").first)
377
+ end
378
+ end
379
+ spans
380
+ end
381
+
382
+ # Bindings whose constant is never used in the file. Languages give
383
+ # the spans a constant occupies, the binding included.
384
+ def unused_binding_diagnostics(analysis)
385
+ Imports.bindings(analysis.lines).filter_map do |name, _specifier|
386
+ spans = rename_spans(analysis, name)
387
+ next if spans.length > 1
388
+
389
+ span = spans.first || next
390
+ Imports::Interface::Diagnostic.new(
391
+ range: span.to_range,
392
+ severity: Imports::Constant::DiagnosticSeverity::WARNING,
393
+ source: "klenod",
394
+ message: "#{name} is imported but never used",
395
+ tags: [Imports::Constant::DiagnosticTag::UNNECESSARY]
396
+ )
397
+ end
398
+ end
399
+
400
+ # Quick fixes replacing an unresolved import literal with each of the
401
+ # build's own suggestions, for literals inside the requested lines.
402
+ def code_actions(analysis, lines, workspace)
403
+ uri = workspace.uri_for_module_id(analysis.module_id)
404
+ return [] unless uri
405
+
406
+ analysis.resolve_errors.flat_map do |error|
407
+ specifier = error.requested_specifier
408
+ span = specifier && Diagnostics.literal_span(specifier, analysis.lines, syntax: syntax)
409
+ next [] unless span && lines.cover?(span.line)
410
+
411
+ diagnostic = Diagnostics.diagnostic(span, error.message)
412
+ error.suggestions.each_with_index.map do |suggestion, index|
413
+ Imports::Interface::CodeAction.new(
414
+ title: "Replace with #{suggestion.inspect}",
415
+ kind: Imports::Constant::CodeActionKind::QUICK_FIX,
416
+ diagnostics: [diagnostic],
417
+ is_preferred: index.zero?,
418
+ edit: Imports::Interface::WorkspaceEdit.new(
419
+ changes: {uri => [Imports::Interface::TextEdit.new(range: span.to_range, new_text: suggestion)]}
420
+ )
421
+ )
422
+ end
423
+ end
424
+ end
425
+ end
426
+ end
427
+ end
428
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../diagnostics"
4
+ require_relative "../symbols"
5
+ require_relative "imports"
6
+
7
+ module Klenod
8
+ module LSP
9
+ module Languages
10
+ # Ruby modules under the source directory: build diagnostics plus
11
+ # navigation and completion for their `import("...")` literals. General
12
+ # Ruby language features are left to a Ruby language server.
13
+ class Ruby
14
+ include ImportNavigation
15
+
16
+ Interface = LanguageServer::Protocol::Interface
17
+
18
+ def diagnostics(analysis, _workspace = nil, _index = nil)
19
+ Diagnostics.for_analysis(analysis) + unused_binding_diagnostics(analysis)
20
+ end
21
+
22
+ def document_symbols(analysis)
23
+ Symbols.binding_symbols(analysis.source)
24
+ end
25
+
26
+ def completion(analysis, position, workspace, _index = nil)
27
+ line_text = analysis.lines[position.line]
28
+ return nil unless line_text
29
+
30
+ items = Imports.completion_items(line_text[0...position.character].to_s, position, analysis, workspace, syntax: syntax)
31
+ items && Interface::CompletionList.new(is_incomplete: false, items: items)
32
+ end
33
+
34
+ def target_at(analysis, position)
35
+ line_text = analysis.lines[position.line]
36
+ line_text && Imports.target_at(line_text, position, syntax: syntax)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../text"
4
+
5
+ module Klenod
6
+ module LSP
7
+ module Languages
8
+ # How a language spells references to other modules. Each syntax finds
9
+ # the literals on a line, knows the dependency kind Klenod resolves them
10
+ # with, and recognizes a literal still being typed for completion.
11
+ module Syntax
12
+ Literal = Data.define(:specifier, :span, :kind)
13
+
14
+ module Ruby
15
+ IMPORT_CALL = /\b(?<call>(?:lazy_)?import(?:_glob)?)\(\s*(?<quote>["'])(?<specifier>[^"']*)\k<quote>/
16
+ IMPORT_PREFIX = /\b(?:lazy_)?import(?:_glob)?\(\s*["'](?<partial>[^"']*)\z/
17
+
18
+ module_function
19
+
20
+ # Glob imports name many modules and are skipped.
21
+ def each_literal(line_text, line_index)
22
+ Text.each_match(line_text, line_index, IMPORT_CALL, group: :specifier) do |match, span|
23
+ next if match[:call] == "import_glob"
24
+
25
+ yield Literal.new(match[:specifier], span, :haml_import)
26
+ end
27
+ end
28
+
29
+ def prefix_partial(prefix)
30
+ IMPORT_PREFIX.match(prefix)&.[](:partial)
31
+ end
32
+ end
33
+
34
+ module CSS
35
+ IMPORT = /@import\s+(?:url\(\s*)?(?<quote>["']?)(?<specifier>[^"'()\s;]+)\k<quote>\s*\)?/
36
+ URL = /\burl\(\s*(?<quote>["']?)(?<specifier>[^"'()]+)\k<quote>\s*\)/
37
+ COMPOSES = /\bfrom\s+(?<quote>["'])(?<specifier>[^"']+)\k<quote>/
38
+ IMPORT_PREFIX = /(?:@import\s+(?:url\(\s*)?|\burl\(\s*|\bfrom\s+)(?<quote>["']?)(?<partial>[^"'()\s;]*)\z/
39
+ SKIPPED = /\A(?:data:|#|\z)/
40
+
41
+ module_function
42
+
43
+ # `@import` first, so the `url()` inside one is not counted twice.
44
+ def each_literal(line_text, line_index)
45
+ covered = []
46
+ [[IMPORT, :css_import], [URL, :asset_url], [COMPOSES, :css_compose]].each do |pattern, kind|
47
+ Text.each_match(line_text, line_index, pattern, group: :specifier) do |match, span|
48
+ next if match[:specifier].match?(SKIPPED)
49
+ next if covered.any? { |range| range.cover?(span.start_character) }
50
+
51
+ covered << (match.begin(0)...match.end(0))
52
+ yield Literal.new(match[:specifier], span, kind)
53
+ end
54
+ end
55
+ end
56
+
57
+ def prefix_partial(prefix)
58
+ IMPORT_PREFIX.match(prefix)&.[](:partial)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "languages/css"
4
+ require_relative "languages/haml"
5
+ require_relative "languages/ruby"
6
+ require_relative "languages/syntax"
7
+
8
+ module Klenod
9
+ module LSP
10
+ # Maps a document to the handler that knows its syntax. Handlers respond
11
+ # to `diagnostics(analysis, workspace, index)`, `document_links(analysis, workspace)`,
12
+ # `code_actions(analysis, lines, workspace)`, `document_symbols(analysis)`,
13
+ # `code_lenses(analysis, workspace, index)`, `prepare_rename(analysis,
14
+ # position)`, `rename(analysis, position, new_name, workspace)`, `references(analysis,
15
+ # position, workspace, index, include_declaration:)`, and to `definition`,
16
+ # `hover`, and `completion`, each taking `(analysis, position, workspace, index)`.
17
+ module Languages
18
+ HANDLERS = {
19
+ ".haml" => Haml.new,
20
+ ".rb" => Ruby.new,
21
+ ".css" => CSS.new
22
+ }.freeze
23
+
24
+ module_function
25
+
26
+ def for(document)
27
+ return nil unless document.module_id
28
+
29
+ HANDLERS[document.extname]
30
+ end
31
+
32
+ # The import syntax of a file by extension, Ruby-shaped by default.
33
+ def syntax_for(extname)
34
+ HANDLERS[extname]&.syntax || Syntax::Ruby
35
+ end
36
+ end
37
+ end
38
+ end