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,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "language_server-protocol"
|
|
4
|
+
|
|
5
|
+
require_relative "../../text"
|
|
6
|
+
|
|
7
|
+
module Klenod
|
|
8
|
+
module LSP
|
|
9
|
+
module Languages
|
|
10
|
+
class Haml
|
|
11
|
+
# CSS class names in a Haml component: the `.name` shorthand on tags
|
|
12
|
+
# and `ClassNames[:name]` lookups, checked against the class map of
|
|
13
|
+
# the companion stylesheet and any inline `:css` filters. The map
|
|
14
|
+
# comes from the graph's records, collected on demand.
|
|
15
|
+
module Classes
|
|
16
|
+
Interface = LanguageServer::Protocol::Interface
|
|
17
|
+
Constant = LanguageServer::Protocol::Constant
|
|
18
|
+
|
|
19
|
+
TAG_HEAD = /\A\s*(?:%[A-Za-z][\w:-]*)?(?<shorthand>(?:[.#][\w-]+)+)/
|
|
20
|
+
SHORTHAND_CLASS = /\.(?<name>[\w-]+)/
|
|
21
|
+
LOOKUP = /ClassNames\[:(?<name>[\w-]+)\]/
|
|
22
|
+
SHORTHAND_PREFIX = /\A\s*(?:%[A-Za-z][\w:-]*)?(?:[.#][\w-]+)*\.(?<partial>[\w-]*)\z/
|
|
23
|
+
LOOKUP_PREFIX = /ClassNames\[:(?<partial>[\w-]*)\z/
|
|
24
|
+
STYLE_KINDS = %i[companion_style inline_style].freeze
|
|
25
|
+
|
|
26
|
+
Occurrence = Data.define(:name, :span)
|
|
27
|
+
Definition = Data.define(:module_id, :generated)
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# Class name => Definition, or nil when the component has no styles
|
|
32
|
+
# at all, in which case class names are plain HTML classes. While
|
|
33
|
+
# the text does not transform, as with a half-typed attribute hash,
|
|
34
|
+
# the module's last collected record supplies the stylesheets.
|
|
35
|
+
def map(analysis, workspace, index)
|
|
36
|
+
style_ids = style_module_ids(analysis, index)
|
|
37
|
+
return nil if style_ids.empty?
|
|
38
|
+
|
|
39
|
+
style_ids.each_with_object({}) do |module_id, map|
|
|
40
|
+
record = style_record(module_id, workspace, index)
|
|
41
|
+
next unless record
|
|
42
|
+
|
|
43
|
+
record.metadata.fetch(:css_classes, {}).each do |key, generated|
|
|
44
|
+
name = key.to_s
|
|
45
|
+
next if name.start_with?("__")
|
|
46
|
+
|
|
47
|
+
map[name] ||= Definition.new(module_id, generated)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def style_module_ids(analysis, index)
|
|
53
|
+
if (transform = analysis.transform)
|
|
54
|
+
transform.dependencies.filter_map do |dependency|
|
|
55
|
+
next unless STYLE_KINDS.include?(dependency.kind)
|
|
56
|
+
|
|
57
|
+
dependency.metadata[:virtual_module_id] || analysis.resolved_module_id_for(dependency.specifier.to_s)
|
|
58
|
+
end
|
|
59
|
+
else
|
|
60
|
+
record = index.record(analysis.module_id)
|
|
61
|
+
return [] unless record
|
|
62
|
+
|
|
63
|
+
record.resolved_dependencies.filter_map { |resolved| resolved.module_id if STYLE_KINDS.include?(resolved.dependency.kind) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def occurrences(lines)
|
|
68
|
+
lines.each_with_index.flat_map do |line_text, index|
|
|
69
|
+
found = []
|
|
70
|
+
if (head = TAG_HEAD.match(line_text))
|
|
71
|
+
offset = head.begin(:shorthand)
|
|
72
|
+
Text.each_match(head[:shorthand], index, SHORTHAND_CLASS, group: :name) do |match, span|
|
|
73
|
+
found << Occurrence.new(match[:name], span.with(start_character: span.start_character + offset, end_character: span.end_character + offset))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
Text.each_match(line_text, index, LOOKUP, group: :name) { |match, span| found << Occurrence.new(match[:name], span) }
|
|
77
|
+
found
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def occurrence_at(lines, position)
|
|
82
|
+
line_text = lines[position.line]
|
|
83
|
+
return nil unless line_text
|
|
84
|
+
|
|
85
|
+
occurrences([line_text]).map { |occurrence| occurrence.with(span: occurrence.span.with(line: position.line)) }
|
|
86
|
+
.find { |occurrence| occurrence.span.include?(position) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Warnings for classes the stylesheets do not define. Components
|
|
90
|
+
# without styles are left alone.
|
|
91
|
+
def diagnostics(analysis, workspace, index)
|
|
92
|
+
map = map(analysis, workspace, index)
|
|
93
|
+
return [] unless map
|
|
94
|
+
|
|
95
|
+
occurrences(analysis.lines).filter_map do |occurrence|
|
|
96
|
+
next if map.key?(occurrence.name)
|
|
97
|
+
|
|
98
|
+
Interface::Diagnostic.new(
|
|
99
|
+
range: occurrence.span.to_range,
|
|
100
|
+
severity: Constant::DiagnosticSeverity::WARNING,
|
|
101
|
+
source: "klenod",
|
|
102
|
+
message: "Unknown CSS class #{occurrence.name.inspect}: not defined in #{style_names(map, analysis).join(" or ")}"
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The selector in the stylesheet, or the `:css` filter line for an
|
|
108
|
+
# inline stylesheet.
|
|
109
|
+
def definition(occurrence, analysis, workspace, index)
|
|
110
|
+
map = map(analysis, workspace, index)
|
|
111
|
+
definition = map && map[occurrence.name]
|
|
112
|
+
return nil unless definition
|
|
113
|
+
|
|
114
|
+
selector_location(definition.module_id, occurrence.name, analysis, workspace, index)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def hover(occurrence, analysis, workspace, index)
|
|
118
|
+
map = map(analysis, workspace, index)
|
|
119
|
+
definition = map && map[occurrence.name]
|
|
120
|
+
return nil unless definition
|
|
121
|
+
|
|
122
|
+
value = "**.#{occurrence.name}** · `#{style_name(definition.module_id, analysis)}`\n\nRendered as `#{definition.generated}`"
|
|
123
|
+
Interface::Hover.new(
|
|
124
|
+
contents: Interface::MarkupContent.new(kind: Constant::MarkupKind::MARKDOWN, value: value),
|
|
125
|
+
range: occurrence.span.to_range
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def completion_items(prefix, position, analysis, workspace, index)
|
|
130
|
+
match = SHORTHAND_PREFIX.match(prefix) || LOOKUP_PREFIX.match(prefix)
|
|
131
|
+
return nil unless match
|
|
132
|
+
|
|
133
|
+
map = map(analysis, workspace, index)
|
|
134
|
+
return [] unless map
|
|
135
|
+
|
|
136
|
+
partial = match[:partial]
|
|
137
|
+
range = Text::Span.new(position.line, position.character - partial.length, position.character).to_range
|
|
138
|
+
map.filter_map do |name, definition|
|
|
139
|
+
next unless name.start_with?(partial)
|
|
140
|
+
|
|
141
|
+
Interface::CompletionItem.new(
|
|
142
|
+
label: name,
|
|
143
|
+
kind: Constant::CompletionItemKind::PROPERTY,
|
|
144
|
+
detail: style_name(definition.module_id, analysis),
|
|
145
|
+
text_edit: Interface::TextEdit.new(range: range, new_text: name)
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def style_record(module_id, workspace, index)
|
|
151
|
+
index.record(module_id) || workspace.context.graph.collect_module(module_id)
|
|
152
|
+
rescue StandardError, ScriptError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def style_names(map, analysis)
|
|
157
|
+
map.values.map(&:module_id).uniq.map { |module_id| style_name(module_id, analysis) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def style_name(module_id, analysis)
|
|
161
|
+
inline_origin(module_id, analysis) ? "inline :css" : module_id.path
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def inline_origin(module_id, analysis)
|
|
165
|
+
return nil unless module_id.scheme == :app && module_id.path.start_with?("#{analysis.module_id.path}.inline.")
|
|
166
|
+
|
|
167
|
+
module_id
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def selector_location(module_id, name, analysis, workspace, index)
|
|
171
|
+
record = style_record(module_id, workspace, index)
|
|
172
|
+
return nil unless record
|
|
173
|
+
|
|
174
|
+
selector = /\.#{Regexp.escape(name)}(?![\w-])/
|
|
175
|
+
css_lines = record.source.lines(chomp: true)
|
|
176
|
+
line_index = css_lines.index { |line_text| line_text.match?(selector) }
|
|
177
|
+
|
|
178
|
+
if inline_origin(module_id, analysis)
|
|
179
|
+
origin = workspace.context.graph.virtual_module_metadata(module_id)[:inline_css_origin]
|
|
180
|
+
uri = workspace.uri_for_module_id(analysis.module_id)
|
|
181
|
+
return nil unless origin && uri && line_index
|
|
182
|
+
|
|
183
|
+
haml_line = origin.fetch(:line_offset) + line_index
|
|
184
|
+
column = css_lines[line_index].index(selector) + (line_index.zero? ? origin.fetch(:column_offset) : 0)
|
|
185
|
+
Interface::Location.new(uri: uri, range: Text::Span.new(haml_line, column, column + name.length + 1).to_range)
|
|
186
|
+
else
|
|
187
|
+
uri = workspace.uri_for_module_id(module_id)
|
|
188
|
+
return nil unless uri
|
|
189
|
+
|
|
190
|
+
span = Text::Span.new(0, 0, 0)
|
|
191
|
+
if line_index
|
|
192
|
+
column = css_lines[line_index].index(selector)
|
|
193
|
+
span = Text::Span.new(line_index, column, column + name.length + 1)
|
|
194
|
+
end
|
|
195
|
+
Interface::Location.new(uri: uri, range: span.to_range)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "language_server-protocol"
|
|
4
|
+
|
|
5
|
+
require_relative "../imports"
|
|
6
|
+
require_relative "classes"
|
|
7
|
+
|
|
8
|
+
module Klenod
|
|
9
|
+
module LSP
|
|
10
|
+
module Languages
|
|
11
|
+
class Haml
|
|
12
|
+
# Completes `%Component` tags from the constants bound by imports, and
|
|
13
|
+
# defers to the shared import path completion inside `import("...")`.
|
|
14
|
+
#
|
|
15
|
+
# Only the line up to the cursor is inspected, because the tag or
|
|
16
|
+
# literal is usually unterminated while it is being typed.
|
|
17
|
+
module Completion
|
|
18
|
+
Interface = LanguageServer::Protocol::Interface
|
|
19
|
+
Constant = LanguageServer::Protocol::Constant
|
|
20
|
+
|
|
21
|
+
COMPONENT_PREFIX = /%(?<partial>[A-Z][A-Za-z0-9_]*)?\z/
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def call(analysis, position, workspace, index = nil)
|
|
26
|
+
line_text = analysis.lines[position.line]
|
|
27
|
+
return nil unless line_text
|
|
28
|
+
|
|
29
|
+
prefix = line_text[0...position.character].to_s
|
|
30
|
+
items = component_items(prefix, position, analysis, workspace) || Imports.completion_items(prefix, position, analysis, workspace)
|
|
31
|
+
items ||= Classes.completion_items(prefix, position, analysis, workspace, index) if index
|
|
32
|
+
return nil unless items
|
|
33
|
+
|
|
34
|
+
Interface::CompletionList.new(is_incomplete: false, items: items)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def component_items(prefix, position, analysis, workspace)
|
|
38
|
+
match = COMPONENT_PREFIX.match(prefix)
|
|
39
|
+
return nil unless match
|
|
40
|
+
|
|
41
|
+
partial = match[:partial].to_s
|
|
42
|
+
range = Imports.replacement_range(position, partial)
|
|
43
|
+
|
|
44
|
+
Haml.bindings(analysis.lines).filter_map do |name, specifier|
|
|
45
|
+
next unless name.start_with?(partial)
|
|
46
|
+
|
|
47
|
+
module_id = analysis.resolved_module_id_for(specifier) || workspace.resolve(specifier, importer_id: analysis.module_id)
|
|
48
|
+
Interface::CompletionItem.new(
|
|
49
|
+
label: name,
|
|
50
|
+
kind: Constant::CompletionItemKind::CLASS,
|
|
51
|
+
detail: module_id&.to_s || specifier,
|
|
52
|
+
text_edit: Interface::TextEdit.new(range: range, new_text: name)
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
require "language_server-protocol"
|
|
5
|
+
|
|
6
|
+
require_relative "../../text"
|
|
7
|
+
require_relative "../imports"
|
|
8
|
+
require_relative "ruby_regions"
|
|
9
|
+
|
|
10
|
+
module Klenod
|
|
11
|
+
module LSP
|
|
12
|
+
module Languages
|
|
13
|
+
class Haml
|
|
14
|
+
# The props a `%Component` tag passes, checked against the props the
|
|
15
|
+
# component reads. Only keys written out on the tag line count:
|
|
16
|
+
# `%Foo(bar="1"){ baz: 2 }`. A splat makes the tag unverifiable, and
|
|
17
|
+
# a component that reads no `$props`, or reads them all with `$*`,
|
|
18
|
+
# is left alone.
|
|
19
|
+
module Props
|
|
20
|
+
Interface = LanguageServer::Protocol::Interface
|
|
21
|
+
Constant = LanguageServer::Protocol::Constant
|
|
22
|
+
|
|
23
|
+
TAG_LINE = /\A\s*%(?<name>[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)(?:[.#][\w-]+)*/
|
|
24
|
+
# `key="value"`, `key=value`, or a bare boolean `key`; a consumed
|
|
25
|
+
# value cannot be mistaken for the next key.
|
|
26
|
+
HTML_KEY = /(?<![\w-])(?<key>[A-Za-z_][\w-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s()]+))?/
|
|
27
|
+
HASH_KEYS = [
|
|
28
|
+
/(?<=[{,]|\A)\s*(?<key>[A-Za-z_]\w*):(?!:)/,
|
|
29
|
+
/(?<=[{,]|\A)\s*"(?<key>[^"]+)":/,
|
|
30
|
+
/(?<=[{,]|\A)\s*:(?<key>\w+)\s*=>/,
|
|
31
|
+
/(?<=[{,]|\A)\s*"(?<key>[^"]+)"\s*=>/
|
|
32
|
+
].freeze
|
|
33
|
+
SPLAT = /\*\*/
|
|
34
|
+
IMPLICIT = %w[children].freeze
|
|
35
|
+
|
|
36
|
+
Key = Data.define(:name, :span)
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
def diagnostics(analysis, workspace, index)
|
|
41
|
+
lines = analysis.lines
|
|
42
|
+
bindings = Imports.bindings(lines)
|
|
43
|
+
props_cache = {}
|
|
44
|
+
|
|
45
|
+
lines.each_with_index.flat_map do |line_text, line_index|
|
|
46
|
+
tag = TAG_LINE.match(line_text)
|
|
47
|
+
next [] unless tag
|
|
48
|
+
|
|
49
|
+
constant = tag[:name].split("::").first
|
|
50
|
+
specifier = bindings[constant]
|
|
51
|
+
next [] unless specifier
|
|
52
|
+
|
|
53
|
+
keys = explicit_keys(line_text[tag.end(0)..].to_s, line_index, tag.end(0))
|
|
54
|
+
next [] if keys.nil? || keys.empty?
|
|
55
|
+
|
|
56
|
+
props = props_cache[specifier] ||= component_props(specifier, analysis, workspace, index)
|
|
57
|
+
next [] unless props && !props.splat && !props.names.empty?
|
|
58
|
+
|
|
59
|
+
allowed = props.names + IMPLICIT
|
|
60
|
+
keys.reject { |key| allowed.include?(key.name) }.map { |key| diagnostic(key, tag[:name], allowed) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Keys from the tag's attribute regions, or nil when a splat makes
|
|
65
|
+
# them unknowable.
|
|
66
|
+
def explicit_keys(rest, line_index, offset)
|
|
67
|
+
keys = []
|
|
68
|
+
RubyRegions.attribute_regions(rest).each do |region_offset, region|
|
|
69
|
+
return nil if region.start_with?("{") && region.match?(SPLAT)
|
|
70
|
+
|
|
71
|
+
patterns = region.start_with?("(") ? [HTML_KEY] : HASH_KEYS
|
|
72
|
+
inner = region[1...-1]
|
|
73
|
+
patterns.each do |pattern|
|
|
74
|
+
Text.each_match(inner, line_index, pattern, group: :key) do |match, span|
|
|
75
|
+
start = offset + region_offset + 1 + span.start_character
|
|
76
|
+
keys << Key.new(match[:key], Text::Span.new(line_index, start, start + match[:key].length))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
keys.uniq { |key| key.span.start_character }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def component_props(specifier, analysis, workspace, index)
|
|
84
|
+
module_id = analysis.resolved_module_id_for(specifier) || workspace.resolve(specifier, importer_id: analysis.module_id)
|
|
85
|
+
return nil unless module_id
|
|
86
|
+
|
|
87
|
+
source = index.record(module_id)&.source
|
|
88
|
+
path = workspace.path_for_module_id(module_id)
|
|
89
|
+
source ||= File.read(path) if path && File.file?(path)
|
|
90
|
+
return nil unless source && module_id.extname == ".haml"
|
|
91
|
+
|
|
92
|
+
Imports.component_props(source, workspace)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def diagnostic(key, tag_name, allowed)
|
|
96
|
+
suggestion = DidYouMean::SpellChecker.new(dictionary: allowed).correct(key.name).first
|
|
97
|
+
message = "Unknown prop #{key.name.inspect} for #{tag_name}"
|
|
98
|
+
message += "; did you mean #{suggestion.inspect}?" if suggestion
|
|
99
|
+
|
|
100
|
+
Interface::Diagnostic.new(
|
|
101
|
+
range: key.span.to_range,
|
|
102
|
+
severity: Constant::DiagnosticSeverity::WARNING,
|
|
103
|
+
source: "klenod",
|
|
104
|
+
message: message
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../text"
|
|
4
|
+
require_relative "../imports"
|
|
5
|
+
require_relative "ruby_regions"
|
|
6
|
+
|
|
7
|
+
module Klenod
|
|
8
|
+
module LSP
|
|
9
|
+
module Languages
|
|
10
|
+
class Haml
|
|
11
|
+
# Where a bound constant appears in a Haml document: the `%Name` tags
|
|
12
|
+
# and the whole-word uses inside Ruby, meaning `:ruby` filters, script
|
|
13
|
+
# lines, tag attributes, and printed tag content. Plain text keeps
|
|
14
|
+
# its words.
|
|
15
|
+
module Rename
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def spans(source, name)
|
|
19
|
+
lines = source.lines(chomp: true)
|
|
20
|
+
spans = []
|
|
21
|
+
|
|
22
|
+
RubyRegions.each(source, lines) do |line_index, start_character, text|
|
|
23
|
+
Imports.constant_spans(text, line_index, name).each do |span|
|
|
24
|
+
spans << span.with(start_character: span.start_character + start_character, end_character: span.end_character + start_character)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
lines.each_with_index do |line_text, index|
|
|
29
|
+
Text.each_match(line_text, index, Haml::COMPONENT_TAG, group: :name) do |match, span|
|
|
30
|
+
next unless match[:name].split("::").first == name
|
|
31
|
+
|
|
32
|
+
spans << Text::Span.new(index, span.start_character, span.start_character + name.length)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
spans.uniq.sort_by { |span| [span.line, span.start_character] }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "klenod/build/plugins/haml_plugin"
|
|
4
|
+
|
|
5
|
+
module Klenod
|
|
6
|
+
module LSP
|
|
7
|
+
module Languages
|
|
8
|
+
class Haml
|
|
9
|
+
# Executable Ruby regions in a Haml document: Ruby filters, script
|
|
10
|
+
# lines, tag attributes, and printed tag content.
|
|
11
|
+
module RubyRegions
|
|
12
|
+
TAG_HEAD = /%[A-Za-z][\w:-]*(?:[.#][\w-]+)*/
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def each(source, lines, &block)
|
|
17
|
+
root = Klenod::Build::Plugins::HamlPlugin.parse_haml(source)
|
|
18
|
+
each_node(root.children, lines, &block)
|
|
19
|
+
rescue Klenod::Build::Error
|
|
20
|
+
lines.each_with_index do |line_text, index|
|
|
21
|
+
next unless line_text.match?(/\A\s*(?:-(?!#)|[=~])/)
|
|
22
|
+
|
|
23
|
+
yield index, 0, line_text
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def each_node(nodes, lines, &block)
|
|
28
|
+
nodes.each do |node|
|
|
29
|
+
line_index = node.line - 1
|
|
30
|
+
case node.type
|
|
31
|
+
when :filter
|
|
32
|
+
if node.value[:name] == "ruby"
|
|
33
|
+
node.value.fetch(:text).to_s.each_line.with_index(node.line) { |_text, index| yield index, 0, lines[index].to_s }
|
|
34
|
+
end
|
|
35
|
+
when :script, :silent_script
|
|
36
|
+
yield line_index, 0, lines[line_index].to_s
|
|
37
|
+
when :tag
|
|
38
|
+
tag(node, lines[line_index].to_s, line_index, &block)
|
|
39
|
+
end
|
|
40
|
+
each_node(node.children, lines, &block) unless node.type == :filter
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def tag(node, line_text, line_index)
|
|
45
|
+
head = TAG_HEAD.match(line_text)
|
|
46
|
+
return unless head
|
|
47
|
+
|
|
48
|
+
rest_start = head.end(0)
|
|
49
|
+
rest = line_text[rest_start..].to_s
|
|
50
|
+
if node.value[:parse]
|
|
51
|
+
yield line_index, rest_start, rest
|
|
52
|
+
else
|
|
53
|
+
attribute_regions(rest).each { |offset, text| yield line_index, rest_start + offset, text }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Attribute hashes and parenthesized attributes, matched by nesting.
|
|
58
|
+
def attribute_regions(rest)
|
|
59
|
+
regions = []
|
|
60
|
+
index = 0
|
|
61
|
+
while index < rest.length
|
|
62
|
+
opener = rest[index]
|
|
63
|
+
if (closer = {"{" => "}", "(" => ")", "[" => "]"}[opener])
|
|
64
|
+
depth = 0
|
|
65
|
+
start = index
|
|
66
|
+
while index < rest.length
|
|
67
|
+
depth += 1 if rest[index] == opener
|
|
68
|
+
depth -= 1 if rest[index] == closer
|
|
69
|
+
index += 1
|
|
70
|
+
break if depth.zero?
|
|
71
|
+
end
|
|
72
|
+
regions << [start, rest[start...index]]
|
|
73
|
+
else
|
|
74
|
+
break unless opener =~ /\s/ || regions.empty?
|
|
75
|
+
|
|
76
|
+
index += 1
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
regions
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../diagnostics"
|
|
4
|
+
require_relative "../symbols"
|
|
5
|
+
require_relative "../text"
|
|
6
|
+
require_relative "imports"
|
|
7
|
+
require_relative "haml/classes"
|
|
8
|
+
require_relative "haml/completion"
|
|
9
|
+
require_relative "haml/props"
|
|
10
|
+
require_relative "haml/rename"
|
|
11
|
+
|
|
12
|
+
module Klenod
|
|
13
|
+
module LSP
|
|
14
|
+
module Languages
|
|
15
|
+
# Diagnostics and navigation for Haml component modules.
|
|
16
|
+
#
|
|
17
|
+
# The Haml parser reports lines only, so everything here recovers
|
|
18
|
+
# columns by scanning the original line text.
|
|
19
|
+
class Haml
|
|
20
|
+
include ImportNavigation
|
|
21
|
+
|
|
22
|
+
COMPONENT_TAG = /%(?<name>[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)/
|
|
23
|
+
|
|
24
|
+
def diagnostics(analysis, workspace = nil, index = nil)
|
|
25
|
+
diagnostics = Diagnostics.for_analysis(analysis)
|
|
26
|
+
diagnostics.concat(Classes.diagnostics(analysis, workspace, index)) if workspace && index
|
|
27
|
+
diagnostics.concat(Props.diagnostics(analysis, workspace, index)) if workspace && index
|
|
28
|
+
diagnostics.concat(unused_binding_diagnostics(analysis))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def completion(analysis, position, workspace, index = nil)
|
|
32
|
+
Completion.call(analysis, position, workspace, index)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def definition(analysis, position, workspace, index = nil)
|
|
36
|
+
occurrence = index && Classes.occurrence_at(analysis.lines, position)
|
|
37
|
+
return Classes.definition(occurrence, analysis, workspace, index) if occurrence
|
|
38
|
+
|
|
39
|
+
super(analysis, position, workspace)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def hover(analysis, position, workspace, index = nil)
|
|
43
|
+
occurrence = index && Classes.occurrence_at(analysis.lines, position)
|
|
44
|
+
return Classes.hover(occurrence, analysis, workspace, index) if occurrence
|
|
45
|
+
|
|
46
|
+
super(analysis, position, workspace)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def document_symbols(analysis)
|
|
50
|
+
Symbols.haml_document_symbols(analysis.source)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def rename_spans(analysis, name)
|
|
54
|
+
Rename.spans(analysis.source, name)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The import literal or `%Component` tag under the cursor.
|
|
58
|
+
def target_at(analysis, position)
|
|
59
|
+
line_text = analysis.lines[position.line]
|
|
60
|
+
return nil unless line_text
|
|
61
|
+
|
|
62
|
+
Imports.target_at(line_text, position, syntax: syntax) || component_target_at(line_text, position, analysis.lines)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `%Details` compiles to the constant `Details`, bound by an import in
|
|
66
|
+
# the leading `:ruby` filter such as `Details = import("/components/Details")`.
|
|
67
|
+
def self.bindings(lines)
|
|
68
|
+
Imports.bindings(lines)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def component_target_at(line_text, position, lines)
|
|
74
|
+
Text.each_match(line_text, position.line, COMPONENT_TAG, group: :name) do |match, span|
|
|
75
|
+
next unless span.include?(position)
|
|
76
|
+
|
|
77
|
+
constant_name = match[:name].split("::").first
|
|
78
|
+
specifier = self.class.bindings(lines)[constant_name]
|
|
79
|
+
return specifier && Imports::Target.new(kind: :component, name: match[:name], specifier: specifier, span: span)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|