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.
- checksums.yaml +7 -0
- data/README.md +114 -0
- data/lib/klenod/lsp/analysis.rb +29 -0
- data/lib/klenod/lsp/cli.rb +33 -0
- data/lib/klenod/lsp/diagnostics.rb +110 -0
- data/lib/klenod/lsp/documents.rb +72 -0
- data/lib/klenod/lsp/graph_index.rb +193 -0
- data/lib/klenod/lsp/languages/css.rb +45 -0
- data/lib/klenod/lsp/languages/haml/classes.rb +202 -0
- data/lib/klenod/lsp/languages/haml/completion.rb +60 -0
- data/lib/klenod/lsp/languages/haml/props.rb +111 -0
- data/lib/klenod/lsp/languages/haml/rename.rb +42 -0
- data/lib/klenod/lsp/languages/haml/ruby_regions.rb +85 -0
- data/lib/klenod/lsp/languages/haml.rb +87 -0
- data/lib/klenod/lsp/languages/imports.rb +428 -0
- data/lib/klenod/lsp/languages/ruby.rb +41 -0
- data/lib/klenod/lsp/languages/syntax.rb +64 -0
- data/lib/klenod/lsp/languages.rb +38 -0
- data/lib/klenod/lsp/renames.rb +141 -0
- data/lib/klenod/lsp/routes.rb +105 -0
- data/lib/klenod/lsp/server.rb +568 -0
- data/lib/klenod/lsp/symbols.rb +177 -0
- data/lib/klenod/lsp/text.rb +78 -0
- data/lib/klenod/lsp/version.rb +7 -0
- data/lib/klenod/lsp/workspace.rb +155 -0
- data/lib/klenod/lsp.rb +9 -0
- metadata +96 -0
|
@@ -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
|