klenod-lsp 0.0.31 → 0.0.33

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d5602eca5221374939807c3f33d43a9ac7794453a73b049e579a77ac229b551
4
- data.tar.gz: 8dd3d801a3a62fc2405b84a221afef3a8ca9b2577a0a33bc3ac73a3a26579651
3
+ metadata.gz: 96ad37bd6ec750e9c26613eddc39034c1ee2b93837d4ef4d9b5427ca72b1072c
4
+ data.tar.gz: e17b0235f5cf7a6142cf5d6042a0f09a75158cf5aa21fb949f1460b0477772e4
5
5
  SHA512:
6
- metadata.gz: cd906ff2ed13c6ab65b62b7215b5d8db1fe2b1999a41a0cd517d9dbe7a44f9ffb0729925c3eed473836f2fab32e61d478d58780445f504e3d13d19a0c10ca829
7
- data.tar.gz: 3f3ca9d50efaaae456a5eece277790d95369e5276585d1c13b742dcf4144bf09a919d1eebbab4c7ecbfc2c58f60a829bae584cab42b681473f9ca50cc1c7ccab
6
+ metadata.gz: a933f96338104e6e505e1a28aecf759215b989c86487c6bb173628d97d52a0898c73e4bedff36270aa6d9fd35d31d91256b103ab5398b9650d781768b956e3dd
7
+ data.tar.gz: 497c4009b5f27f79c08a18f3b9022cb55376f3184152eacfa8f44fe3c039fcc986ba77a5f62dd4daba557fcc5cafcfc4d9ca7b69f76d5aa7444438efe5da6e92
data/README.md CHANGED
@@ -17,8 +17,11 @@ application code.
17
17
  - Symbol rename within a Haml or Ruby file and import-preserving edits when
18
18
  files or directories move.
19
19
  - Prop and slot information for Haml components, including spelling suggestions
20
- for unknown props.
21
- - Scoped CSS class definition, hover, completion, and validation in Haml.
20
+ for unknown props. Prop checks inspect the outer attribute hash and leave
21
+ nested hash keys and text inside values alone.
22
+ - Scoped CSS class definition, hover, completion, and validation in Haml, for
23
+ `.name` shorthand, symbols in `class:` attributes such as
24
+ `class: [:name, { active: open? }]`, and `ClassNames[:name]` lookups.
22
25
  - Route lenses and hovers when `RouterPlugin` is configured.
23
26
 
24
27
  Ruby support is intentionally limited to Klenod imports and their bound
@@ -1,17 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "language_server-protocol"
4
+ require "prism"
4
5
 
5
6
  require_relative "../../text"
7
+ require_relative "ruby_regions"
6
8
 
7
9
  module Klenod
8
10
  module LSP
9
11
  module Languages
10
12
  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.
13
+ # CSS class names in a Haml component: the `.name` shorthand on tags,
14
+ # symbols in a tag's `class:` attribute, and `ClassNames[:name]`
15
+ # lookups, checked against the class map of the companion stylesheet
16
+ # and any inline `:css` filters. The map comes from the graph's
17
+ # records, collected on demand.
15
18
  module Classes
16
19
  Interface = LanguageServer::Protocol::Interface
17
20
  Constant = LanguageServer::Protocol::Constant
@@ -21,8 +24,12 @@ module Klenod
21
24
  LOOKUP = /ClassNames\[:(?<name>[\w-]+)\]/
22
25
  SHORTHAND_PREFIX = /\A\s*(?:%[A-Za-z][\w:-]*)?(?:[.#][\w-]+)*\.(?<partial>[\w-]*)\z/
23
26
  LOOKUP_PREFIX = /ClassNames\[:(?<partial>[\w-]*)\z/
27
+ CLASS_KEY_PREFIX = /(?:(?<![\w:])class:|:class\s*=>|["']class["']\s*(?::|=>))/
28
+ SYMBOL_PREFIX = /(?:\A|[\s\[,]):(?<partial>[\w-]*)\z/
29
+ OPENERS = "[{("
30
+ CLOSERS = "]})"
24
31
  STYLE_KINDS = %i[companion_style inline_style].freeze
25
- FILTER_HEAD = /\A\s*:\w/
32
+ FILTER_HEAD = /\A\s*:(?<name>\w+)/
26
33
 
27
34
  Occurrence = Data.define(:name, :span)
28
35
  Definition = Data.define(:module_id, :generated)
@@ -65,24 +72,76 @@ module Klenod
65
72
  end
66
73
  end
67
74
 
68
- # Shorthand classes are only read outside filters, so a `:ruby`
69
- # method chain such as `.freeze` is not a class. `ClassNames[:name]`
70
- # lookups count everywhere.
75
+ # Shorthand and `class:` attribute classes are only read outside
76
+ # filters, so a `:ruby` method chain such as `.freeze` is not a
77
+ # class. `ClassNames[:name]` lookups count everywhere.
71
78
  def occurrences(lines)
72
79
  filter_body = filter_body_lines(lines)
73
80
  lines.each_with_index.flat_map do |line_text, index|
74
81
  found = []
75
- if !filter_body.include?(index) && (head = TAG_HEAD.match(line_text))
76
- offset = head.begin(:shorthand)
77
- Text.each_match(head[:shorthand], index, SHORTHAND_CLASS, group: :name) do |match, span|
78
- found << Occurrence.new(match[:name], span.with(start_character: span.start_character + offset, end_character: span.end_character + offset))
82
+ unless filter_body.include?(index)
83
+ if (head = TAG_HEAD.match(line_text))
84
+ offset = head.begin(:shorthand)
85
+ Text.each_match(head[:shorthand], index, SHORTHAND_CLASS, group: :name) do |match, span|
86
+ found << Occurrence.new(match[:name], span.with(start_character: span.start_character + offset, end_character: span.end_character + offset))
87
+ end
79
88
  end
89
+ found.concat(attribute_occurrences(line_text, index))
80
90
  end
81
91
  Text.each_match(line_text, index, LOOKUP, group: :name) { |match, span| found << Occurrence.new(match[:name], span) }
82
92
  found
83
93
  end
84
94
  end
85
95
 
96
+ # Symbols in the `class:` value of an attribute hash closed on the
97
+ # tag line: `class: :name`, `:class => :name`, symbols in an array,
98
+ # and symbol keys of a conditional hash such as `{ active: open? }`.
99
+ # Computed values are left alone, and strings are unscoped classes.
100
+ def attribute_occurrences(line_text, line_index)
101
+ head = RubyRegions::TAG_HEAD.match(line_text)
102
+ return [] unless head
103
+
104
+ RubyRegions.attribute_regions(line_text[head.end(0)..].to_s).flat_map do |region_offset, region|
105
+ next [] unless region.start_with?("{")
106
+
107
+ result = Prism.parse(region)
108
+ hash = result.value.statements.body.first
109
+ next [] unless result.success? && hash.is_a?(Prism::HashNode)
110
+
111
+ offset = head.end(0) + region_offset
112
+ hash.elements.flat_map do |element|
113
+ next [] unless element.is_a?(Prism::AssocNode) && class_key?(element.key)
114
+
115
+ class_symbols(element.value).map do |symbol|
116
+ location = symbol.value_loc
117
+ Occurrence.new(
118
+ symbol.unescaped,
119
+ Text::Span.new(line_index, offset + location.start_character_offset, offset + location.end_character_offset)
120
+ )
121
+ end
122
+ end
123
+ end
124
+ end
125
+
126
+ def class_key?(node)
127
+ (node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)) && node.unescaped == "class"
128
+ end
129
+
130
+ def class_symbols(node)
131
+ case node
132
+ when Prism::SymbolNode
133
+ node.value_loc ? [node] : []
134
+ when Prism::ArrayNode
135
+ node.elements.flat_map { class_symbols(it) }
136
+ when Prism::HashNode, Prism::KeywordHashNode
137
+ node.elements.flat_map do |element|
138
+ (element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode)) ? class_symbols(element.key) : []
139
+ end
140
+ else
141
+ []
142
+ end
143
+ end
144
+
86
145
  def occurrence_at(lines, position)
87
146
  return nil unless lines[position.line]
88
147
 
@@ -92,14 +151,16 @@ module Klenod
92
151
  # Indexes of the lines inside filters: every line after a `:name`
93
152
  # line that is blank or indented deeper than it, as Haml reads them.
94
153
  # Indentation alone also works while the document does not parse.
95
- def filter_body_lines(lines)
154
+ # Filters named in `except` are left out.
155
+ def filter_body_lines(lines, except: [])
96
156
  filter_indent = nil
97
157
  lines.each_with_index.with_object(Set.new) do |(line_text, index), body|
98
158
  indent = line_text[/\A\s*/].length
99
159
  if filter_indent && (line_text.strip.empty? || indent > filter_indent)
100
160
  body << index
101
161
  else
102
- filter_indent = line_text.match?(FILTER_HEAD) ? indent : nil
162
+ head = FILTER_HEAD.match(line_text)
163
+ filter_indent = (head && !except.include?(head[:name])) ? indent : nil
103
164
  end
104
165
  end
105
166
  end
@@ -146,7 +207,7 @@ module Klenod
146
207
 
147
208
  def completion_items(prefix, position, analysis, workspace, index)
148
209
  in_filter = filter_body_lines(analysis.lines).include?(position.line)
149
- match = (!in_filter && SHORTHAND_PREFIX.match(prefix)) || LOOKUP_PREFIX.match(prefix)
210
+ match = (!in_filter && (SHORTHAND_PREFIX.match(prefix) || class_symbol_prefix(prefix))) || LOOKUP_PREFIX.match(prefix)
150
211
  return nil unless match
151
212
 
152
213
  map = map(analysis, workspace, index)
@@ -166,6 +227,26 @@ module Klenod
166
227
  end
167
228
  end
168
229
 
230
+ # A symbol being typed in a tag's `class:` value, such as
231
+ # `%p{ class: [:lead, :k`. A comma outside brackets ends the value,
232
+ # so the next attribute's symbols are not completed as classes.
233
+ def class_symbol_prefix(prefix)
234
+ return nil unless RubyRegions::TAG_HEAD.match?(prefix)
235
+
236
+ key = prefix.to_enum(:scan, CLASS_KEY_PREFIX).map { Regexp.last_match }.last
237
+ return nil unless key
238
+
239
+ value = prefix[key.end(0)..]
240
+ depth = 0
241
+ value.each_char do |char|
242
+ depth += 1 if OPENERS.include?(char)
243
+ depth -= 1 if CLOSERS.include?(char)
244
+ return nil if depth.negative? || (depth.zero? && char == ",")
245
+ end
246
+
247
+ SYMBOL_PREFIX.match(value)
248
+ end
249
+
169
250
  def style_record(module_id, workspace, index)
170
251
  index.record(module_id) || workspace.context.graph.collect_module(module_id)
171
252
  rescue StandardError, ScriptError
@@ -2,9 +2,11 @@
2
2
 
3
3
  require "did_you_mean"
4
4
  require "language_server-protocol"
5
+ require "prism"
5
6
 
6
7
  require_relative "../../text"
7
8
  require_relative "../imports"
9
+ require_relative "classes"
8
10
  require_relative "ruby_regions"
9
11
 
10
12
  module Klenod
@@ -24,13 +26,6 @@ module Klenod
24
26
  # `key="value"`, `key=value`, or a bare boolean `key`; a consumed
25
27
  # value cannot be mistaken for the next key.
26
28
  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
29
  CLOSERS = {"{" => "}", "(" => ")", "[" => "]"}.freeze
35
30
  # Props every component accepts: Haml passes `children`, `%Foo[item]`
36
31
  # passes `key`, and `slot` places the component in its parent's slot.
@@ -42,10 +37,13 @@ module Klenod
42
37
 
43
38
  def diagnostics(analysis, workspace, index)
44
39
  lines = analysis.lines
45
- bindings = Imports.bindings(lines)
40
+ bindings = Haml.bindings(lines)
46
41
  props_cache = {}
42
+ filter_body = Classes.filter_body_lines(lines)
47
43
 
48
44
  lines.each_with_index.flat_map do |line_text, line_index|
45
+ next [] if filter_body.include?(line_index)
46
+
49
47
  tag = TAG_LINE.match(line_text)
50
48
  next [] unless tag
51
49
 
@@ -75,13 +73,15 @@ module Klenod
75
73
  def explicit_keys(rest, line_index, offset)
76
74
  keys = []
77
75
  RubyRegions.attribute_regions(rest).each do |region_offset, region|
78
- return nil if region.start_with?("{") && region.match?(SPLAT)
79
76
  return nil unless region.end_with?(CLOSERS.fetch(region[0]))
80
77
 
81
- patterns = region.start_with?("(") ? [HTML_KEY] : HASH_KEYS
82
- inner = region[1...-1]
83
- patterns.each do |pattern|
84
- Text.each_match(inner, line_index, pattern, group: :key) do |match, span|
78
+ if region.start_with?("{")
79
+ hash_keys = explicit_hash_keys(region, line_index, offset + region_offset)
80
+ return nil unless hash_keys
81
+
82
+ keys.concat(hash_keys)
83
+ elsif region.start_with?("(")
84
+ Text.each_match(region[1...-1], line_index, HTML_KEY, group: :key) do |match, span|
85
85
  start = offset + region_offset + 1 + span.start_character
86
86
  keys << Key.new(match[:key], Text::Span.new(line_index, start, start + match[:key].length))
87
87
  end
@@ -90,6 +90,27 @@ module Klenod
90
90
  keys.uniq { |key| key.span.start_character }
91
91
  end
92
92
 
93
+ # Only the outer hash supplies props. Keys in nested hashes, blocks,
94
+ # and strings belong to values, and only an outer splat prevents us
95
+ # from knowing which props the component receives.
96
+ def explicit_hash_keys(source, line_index, offset)
97
+ result = Prism.parse(source, partial_script: true)
98
+ hash = result.value.statements.body.first
99
+ return nil unless result.success? && hash.is_a?(Prism::HashNode)
100
+ return nil if hash.elements.any? { |element| element.is_a?(Prism::AssocSplatNode) }
101
+
102
+ hash.elements.filter_map do |element|
103
+ name, location =
104
+ case (key = element.key)
105
+ when Prism::SymbolNode then [key.value, key.value_loc]
106
+ when Prism::StringNode then [key.unescaped, key.content_loc]
107
+ else next
108
+ end
109
+ start = offset + source.byteslice(0, location.start_offset).length
110
+ Key.new(name, Text::Span.new(line_index, start, start + location.slice.length))
111
+ end
112
+ end
113
+
93
114
  def component_props(specifier, analysis, workspace, index)
94
115
  module_id = analysis.resolved_module_id_for(specifier) || workspace.resolve(specifier, importer_id: analysis.module_id)
95
116
  return nil unless module_id
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../../text"
4
4
  require_relative "../imports"
5
+ require_relative "classes"
5
6
  require_relative "ruby_regions"
6
7
 
7
8
  module Klenod
@@ -9,9 +10,9 @@ module Klenod
9
10
  module Languages
10
11
  class Haml
11
12
  # 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.
13
+ # outside filters and the whole-word uses inside Ruby, meaning `:ruby`
14
+ # filters, script lines, tag attributes, and printed tag content.
15
+ # Plain text keeps its words.
15
16
  module Rename
16
17
  module_function
17
18
 
@@ -25,7 +26,10 @@ module Klenod
25
26
  end
26
27
  end
27
28
 
29
+ filter_body = Classes.filter_body_lines(lines)
28
30
  lines.each_with_index do |line_text, index|
31
+ next if filter_body.include?(index)
32
+
29
33
  Text.each_match(line_text, index, Haml::COMPONENT_TAG, group: :name) do |match, span|
30
34
  next unless match[:name].split("::").first == name
31
35
 
@@ -68,13 +68,22 @@ module Klenod
68
68
 
69
69
  # `%Details` compiles to the constant `Details`, bound by an import in
70
70
  # the leading `:ruby` filter such as `Details = import("/components/Details")`.
71
+ # Imports in other filters, such as code samples in `:markdown`, are
72
+ # text.
71
73
  def self.bindings(lines)
72
- Imports.bindings(lines)
74
+ text = Classes.filter_body_lines(lines, except: %w[ruby])
75
+ Imports.bindings(lines.reject.with_index { |_line_text, index| text.include?(index) })
76
+ end
77
+
78
+ def bindings(lines)
79
+ self.class.bindings(lines)
73
80
  end
74
81
 
75
82
  private
76
83
 
77
84
  def component_target_at(line_text, position, lines)
85
+ return nil if Classes.filter_body_lines(lines).include?(position.line)
86
+
78
87
  Text.each_match(line_text, position.line, COMPONENT_TAG, group: :name) do |match, span|
79
88
  next unless span.include?(position)
80
89
 
@@ -218,6 +218,11 @@ module Klenod
218
218
  Syntax::Ruby
219
219
  end
220
220
 
221
+ # Constant names bound to imports in a document, keyed by name.
222
+ def bindings(lines)
223
+ Imports.bindings(lines)
224
+ end
225
+
221
226
  def definition(analysis, position, workspace, _index = nil)
222
227
  target = target_at(analysis, position)
223
228
  module_id = target && Imports.resolve(target, analysis, workspace)
@@ -376,11 +381,14 @@ module Klenod
376
381
  def usage_spans(importer_id, lines, specifiers)
377
382
  return [] unless importer_id.extname == ".haml"
378
383
 
379
- names = Imports.bindings(lines).select { |_name, specifier| specifiers.include?(specifier) }.keys
384
+ names = Languages::Haml.bindings(lines).select { |_name, specifier| specifiers.include?(specifier) }.keys
380
385
  return [] if names.empty?
381
386
 
382
387
  spans = []
388
+ filter_body = Languages::Haml::Classes.filter_body_lines(lines)
383
389
  lines.each_with_index do |line_text, index|
390
+ next if filter_body.include?(index)
391
+
384
392
  Text.each_match(line_text, index, Languages::Haml::COMPONENT_TAG, group: :name) do |match, span|
385
393
  spans << span if names.include?(match[:name].split("::").first)
386
394
  end
@@ -391,7 +399,7 @@ module Klenod
391
399
  # Bindings whose constant is never used in the file. Languages give
392
400
  # the spans a constant occupies, the binding included.
393
401
  def unused_binding_diagnostics(analysis)
394
- Imports.bindings(analysis.lines).filter_map do |name, _specifier|
402
+ bindings(analysis.lines).filter_map do |name, _specifier|
395
403
  spans = rename_spans(analysis, name)
396
404
  next if spans.length > 1
397
405
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Klenod
4
4
  module LSP
5
- VERSION = "0.0.31"
5
+ VERSION = "0.0.33"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: klenod-lsp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.31
4
+ version: 0.0.33
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrés Alin
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.0.31
18
+ version: 0.0.33
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.0.31
25
+ version: 0.0.33
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: language_server-protocol
28
28
  requirement: !ruby/object:Gem::Requirement