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,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