klenod-lsp 0.0.30 → 0.0.31

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: 4e64b40c339a0f3b2e2338120f4fce1a631202dad834e0ca30566048db1f5f0f
4
- data.tar.gz: 5458d0f7574457c17214c08e84db53ecb14677fa75f89dffe229de1f1dac8543
3
+ metadata.gz: 0d5602eca5221374939807c3f33d43a9ac7794453a73b049e579a77ac229b551
4
+ data.tar.gz: 8dd3d801a3a62fc2405b84a221afef3a8ca9b2577a0a33bc3ac73a3a26579651
5
5
  SHA512:
6
- metadata.gz: a448b50f1d21c57317f13893c48ee376560cd4324900b2d8fbd1ab841c7d849c6f4d1be99237e4f7893149dafbeb15137ab997220c5ad6c968fb8245ff627e4a
7
- data.tar.gz: 9093c27e1d87afee0d355d121371aa9c37ec555a03e1e9090dc52ee4a0e18f619406b2d8de7f83dfd81d8d0679991af3546f9c5865e5eccd6a04c982bb5eeea8
6
+ metadata.gz: cd906ff2ed13c6ab65b62b7215b5d8db1fe2b1999a41a0cd517d9dbe7a44f9ffb0729925c3eed473836f2fab32e61d478d58780445f504e3d13d19a0c10ca829
7
+ data.tar.gz: 3f3ca9d50efaaae456a5eece277790d95369e5276585d1c13b742dcf4144bf09a919d1eebbab4c7ecbfc2c58f60a829bae584cab42b681473f9ca50cc1c7ccab
data/README.md CHANGED
@@ -54,14 +54,17 @@ bundle exec klenod check src/components src/pages/Home.haml
54
54
  ```
55
55
 
56
56
  ```
57
- src/pages/Home.haml:4:3: warning: Card is imported but never used
58
- src/pages/Home.haml:9:12: error: Could not resolve "./Missing"
57
+ src/pages/Home.haml
58
+ 4:3 warning `Card` is imported but never used
59
+ 9:12 error Could not resolve "./Missing"
60
+
59
61
  1 error, 1 warning, 42 files checked.
60
62
  ```
61
63
 
62
64
  It collects the whole module graph first, so cross-file diagnostics such as
63
65
  unknown props and scoped classes are included. Paths limit which files are
64
66
  reported. The command exits with status 1 when it reports any diagnostic.
67
+ Output is colored in a terminal; set `NO_COLOR` to turn colors off.
65
68
 
66
69
  Configure the editor to run that command for `.haml` files. Ruby and CSS files
67
70
  can also use it alongside their usual language servers.
@@ -26,6 +26,20 @@ module Klenod
26
26
  Constant::DiagnosticSeverity::HINT => "hint"
27
27
  }.freeze
28
28
 
29
+ COLORS = {
30
+ path: "\e[4m",
31
+ location: "\e[2m",
32
+ error: "\e[31m",
33
+ warning: "\e[33m",
34
+ info: "\e[1;34m",
35
+ hint: "\e[2m",
36
+ detail: "\e[2m",
37
+ code: "\e[36m",
38
+ success: "\e[1;32m"
39
+ }.freeze
40
+ RESET = "\e[0m"
41
+ CODE = /`([^`\n]+)`/
42
+
29
43
  # One checked file and its diagnostics, which may be empty.
30
44
  Result = Data.define(:path, :diagnostics)
31
45
 
@@ -58,38 +72,78 @@ module Klenod
58
72
  end
59
73
  end
60
74
 
61
- # Prints one `path:line:column: severity: message` entry per diagnostic
62
- # with paths relative to `root`, then a summary. Returns the number of
63
- # diagnostics.
64
- def self.report(results, output:, root: Dir.pwd)
65
- diagnostics = results.flat_map do |result|
66
- result.diagnostics
75
+ # Prints the diagnostics grouped by file, like ESLint's stylish format,
76
+ # with paths relative to `root`, then a summary:
77
+ #
78
+ # src/pages/Home.haml
79
+ # 4:3 warning `Card` is imported but never used
80
+ # 9:12 error Could not resolve "./Missing"
81
+ #
82
+ # 1 error, 1 warning, 42 files checked.
83
+ #
84
+ # Columns share one width across all files, so every row lines up.
85
+ # Returns the number of diagnostics. Colors default to on for a terminal
86
+ # unless NO_COLOR is set.
87
+ def self.report(results, output:, root: Dir.pwd, color: color?(output))
88
+ paint = color ? ->(name, text) { "#{COLORS.fetch(name)}#{text}#{RESET}" } : ->(_name, text) { text }
89
+ files = results.reject { |result| result.diagnostics.empty? }.map do |result|
90
+ rows = result.diagnostics
67
91
  .sort_by { |diagnostic| [diagnostic.range.start.line, diagnostic.range.start.character] }
68
- .map { |diagnostic| [display_path(result.path, root), diagnostic] }
92
+ .map do |diagnostic|
93
+ start = diagnostic.range.start
94
+ [start.line + 1, start.character + 1, SEVERITY_NAMES.fetch(diagnostic.severity), diagnostic.message]
95
+ end
96
+ [result.path, rows]
69
97
  end
70
-
71
- diagnostics.each do |path, diagnostic|
72
- start = diagnostic.range.start
73
- first_line, *rest = diagnostic.message.lines(chomp: true)
74
- output.puts "#{path}:#{start.line + 1}:#{start.character + 1}: #{SEVERITY_NAMES.fetch(diagnostic.severity)}: #{first_line}"
75
- rest.each { |line| output.puts " #{line}" }
98
+ rows = files.flat_map(&:last)
99
+
100
+ # Line numbers align right and columns left, so the colons line up.
101
+ line_width = rows.map { |line, _, _, _| line.to_s.length }.max
102
+ column_width = rows.map { |_, column, _, _| column.to_s.length }.max
103
+ severity_width = rows.map { |_, _, severity, _| severity.length }.max
104
+ indent = " " * (2 + line_width.to_i + 1 + column_width.to_i + 2 + severity_width.to_i + 2)
105
+
106
+ files.each do |path, file_rows|
107
+ output.puts paint.call(:path, display_path(path, root))
108
+ file_rows.each do |line, column, severity, message|
109
+ location = "#{line.to_s.rjust(line_width)}:#{column.to_s.ljust(column_width)}"
110
+ first_line, *rest = message.lines(chomp: true)
111
+ first_line = highlight(first_line) if color
112
+ output.puts " #{paint.call(:location, location)} #{paint.call(severity.to_sym, severity.ljust(severity_width))} #{first_line}"
113
+ rest.each { |text| output.puts "#{indent}#{color ? highlight(text, COLORS.fetch(:detail)) : text}" }
114
+ end
115
+ output.puts
76
116
  end
77
117
 
78
- output.puts summary(results, diagnostics.map(&:last))
118
+ diagnostics = results.flat_map(&:diagnostics)
119
+ output.puts summary(results, diagnostics, paint)
79
120
  diagnostics.length
80
121
  end
81
122
 
82
- def self.summary(results, diagnostics)
123
+ def self.summary(results, diagnostics, paint = ->(_name, text) { text })
83
124
  files = "#{results.length} #{(results.length == 1) ? "file" : "files"} checked"
84
- return "No problems found, #{files}." if diagnostics.empty?
125
+ return "#{paint.call(:success, "No problems found")}, #{files}." if diagnostics.empty?
85
126
 
86
127
  counts = diagnostics.group_by(&:severity).sort.map do |severity, group|
87
128
  name = SEVERITY_NAMES.fetch(severity)
88
- "#{group.length} #{(group.length == 1) ? name : "#{name}s"}"
129
+ paint.call(name.to_sym, "#{group.length} #{(group.length == 1) ? name : "#{name}s"}")
89
130
  end
90
131
  "#{counts.join(", ")}, #{files}."
91
132
  end
92
133
 
134
+ # Colors backticked names such as `Card` in place of their backticks.
135
+ # `base` is the color of the surrounding text, restored after each name.
136
+ def self.highlight(text, base = nil)
137
+ highlighted = text.gsub(CODE) { "#{COLORS.fetch(:code)}#{Regexp.last_match(1)}#{RESET}#{base}" }
138
+ base ? "#{base}#{highlighted}#{RESET}" : highlighted
139
+ end
140
+
141
+ def self.color?(output, env = ENV)
142
+ return false if env.key?("NO_COLOR")
143
+
144
+ output.respond_to?(:tty?) && output.tty?
145
+ end
146
+
93
147
  def self.display_path(path, root)
94
148
  relative = Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
95
149
  relative.start_with?("../") ? path : relative
@@ -117,7 +117,7 @@ module Klenod
117
117
  range: occurrence.span.to_range,
118
118
  severity: Constant::DiagnosticSeverity::WARNING,
119
119
  source: "klenod",
120
- message: "Unknown CSS class #{occurrence.name.inspect}: not defined in #{style_names(map, analysis).join(" or ")}"
120
+ message: "Unknown CSS class `#{occurrence.name}`: not defined in #{style_names(map, analysis).map { |name| "`#{name}`" }.join(" or ")}"
121
121
  )
122
122
  end
123
123
  end
@@ -13,9 +13,9 @@ module Klenod
13
13
  class Haml
14
14
  # The props a `%Component` tag passes, checked against the props the
15
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.
16
+ # `%Foo(bar="1"){ baz: 2 }`. A splat or attributes spanning several
17
+ # lines make the tag unverifiable, and a component that reads no
18
+ # `$props`, or reads them all with `$*`, is left alone.
19
19
  module Props
20
20
  Interface = LanguageServer::Protocol::Interface
21
21
  Constant = LanguageServer::Protocol::Constant
@@ -31,7 +31,10 @@ module Klenod
31
31
  /(?<=[{,]|\A)\s*"(?<key>[^"]+)"\s*=>/
32
32
  ].freeze
33
33
  SPLAT = /\*\*/
34
- IMPLICIT = %w[children].freeze
34
+ CLOSERS = {"{" => "}", "(" => ")", "[" => "]"}.freeze
35
+ # Props every component accepts: Haml passes `children`, `%Foo[item]`
36
+ # passes `key`, and `slot` places the component in its parent's slot.
37
+ IMPLICIT = %w[children key slot].freeze
35
38
 
36
39
  Key = Data.define(:name, :span)
37
40
 
@@ -57,16 +60,23 @@ module Klenod
57
60
  next [] unless props && !props.splat && !props.names.empty?
58
61
 
59
62
  allowed = props.names + IMPLICIT
60
- keys.reject { |key| allowed.include?(key.name) }.map { |key| diagnostic(key, tag[:name], allowed) }
63
+ keys.reject { |key| allowed.include?(prop_name(key.name)) }.map { |key| diagnostic(key, tag[:name], allowed) }
61
64
  end
62
65
  end
63
66
 
64
- # Keys from the tag's attribute regions, or nil when a splat makes
65
- # them unknowable.
67
+ # Components receive `video-id` as `$video_id`, like `merge_props`
68
+ # in the Haml plugin's helper source.
69
+ def prop_name(key)
70
+ key.tr("-", "_")
71
+ end
72
+
73
+ # Keys from the tag's attribute regions, or nil when a splat or an
74
+ # attribute list continuing on the next lines makes them unknowable.
66
75
  def explicit_keys(rest, line_index, offset)
67
76
  keys = []
68
77
  RubyRegions.attribute_regions(rest).each do |region_offset, region|
69
78
  return nil if region.start_with?("{") && region.match?(SPLAT)
79
+ return nil unless region.end_with?(CLOSERS.fetch(region[0]))
70
80
 
71
81
  patterns = region.start_with?("(") ? [HTML_KEY] : HASH_KEYS
72
82
  inner = region[1...-1]
@@ -93,9 +103,9 @@ module Klenod
93
103
  end
94
104
 
95
105
  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
106
+ suggestion = DidYouMean::SpellChecker.new(dictionary: allowed).correct(prop_name(key.name)).first
107
+ message = "Unknown prop `#{key.name}` for `#{tag_name}`"
108
+ message += "; did you mean `#{suggestion}`?" if suggestion
99
109
 
100
110
  Interface::Diagnostic.new(
101
111
  range: key.span.to_range,
@@ -9,7 +9,8 @@ module Klenod
9
9
  # Executable Ruby regions in a Haml document: Ruby filters, script
10
10
  # lines, tag attributes, and printed tag content.
11
11
  module RubyRegions
12
- TAG_HEAD = /%[A-Za-z][\w:-]*(?:[.#][\w-]+)*/
12
+ # `%name.class#id`, or `.class#id` for an implicit `%div`.
13
+ TAG_HEAD = /\A\s*(?:%[A-Za-z][\w:-]*(?:[.#][\w-]+)*|(?:[.#][\w-]+)+)/
13
14
 
14
15
  module_function
15
16
 
@@ -35,22 +36,50 @@ module Klenod
35
36
  when :script, :silent_script
36
37
  yield line_index, 0, lines[line_index].to_s
37
38
  when :tag
38
- tag(node, lines[line_index].to_s, line_index, &block)
39
+ tag(node, lines, line_index, &block)
39
40
  end
40
41
  each_node(node.children, lines, &block) unless node.type == :filter
41
42
  end
42
43
  end
43
44
 
44
- def tag(node, line_text, line_index)
45
+ def tag(node, lines, line_index, &block)
46
+ line_text = lines[line_index].to_s
45
47
  head = TAG_HEAD.match(line_text)
46
48
  return unless head
47
49
 
48
50
  rest_start = head.end(0)
49
51
  rest = line_text[rest_start..].to_s
52
+ regions = attribute_regions(rest)
50
53
  if node.value[:parse]
51
54
  yield line_index, rest_start, rest
52
55
  else
53
- attribute_regions(rest).each { |offset, text| yield line_index, rest_start + offset, text }
56
+ regions.each { |offset, text| yield line_index, rest_start + offset, text }
57
+ end
58
+ continuation(lines, line_index + 1, regions.last&.last, parse: node.value[:parse], &block)
59
+ end
60
+
61
+ # The lines of an attribute region left open on the tag line, up to
62
+ # and including its closer. A tag printing Ruby content keeps the
63
+ # rest of the closing line too: `}= value`.
64
+ def continuation(lines, line_index, region, parse: false)
65
+ return unless region
66
+
67
+ opener = region[0]
68
+ closer = {"{" => "}", "(" => ")", "[" => "]"}.fetch(opener)
69
+ depth = region.count(opener) - region.count(closer)
70
+ while depth.positive? && line_index < lines.length
71
+ line_text = lines[line_index].to_s
72
+ length = line_text.length
73
+ line_text.each_char.with_index do |char, index|
74
+ depth += 1 if char == opener
75
+ depth -= 1 if char == closer
76
+ next unless depth.zero?
77
+
78
+ length = index + 1 unless parse
79
+ break
80
+ end
81
+ yield line_index, 0, line_text[0...length]
82
+ line_index += 1
54
83
  end
55
84
  end
56
85
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "language_server-protocol"
4
+ require "prism"
4
5
 
5
6
  require "klenod/build/module_id"
6
7
 
@@ -116,6 +117,7 @@ module Klenod
116
117
  tokens = []
117
118
  slots = []
118
119
  Haml::RubyRegions.each(source, lines) do |_line_index, _start_character, ruby_source|
120
+ ruby_source = without_comment(ruby_source)
119
121
  tokens.concat(ruby_source.scan(PROP_TOKEN).flatten)
120
122
  slots.concat(ruby_source.scan(SLOT_TOKEN).flatten)
121
123
  end
@@ -126,6 +128,13 @@ module Klenod
126
128
  )
127
129
  end
128
130
 
131
+ # The Ruby before a trailing `# comment`, so commented-out code reads
132
+ # no props. Prism tells a comment from `#` inside a string.
133
+ def without_comment(ruby_source)
134
+ comment = Prism.lex(ruby_source).value.find { |token, _state| token.type == :COMMENT }&.first
135
+ comment ? ruby_source.byteslice(0, comment.location.start_offset) : ruby_source
136
+ end
137
+
129
138
  # Completion items for the path being typed inside an import literal,
130
139
  # or nil when the line prefix is not inside one.
131
140
  def completion_items(prefix, position, analysis, workspace, syntax: Syntax::Ruby)
@@ -391,7 +400,7 @@ module Klenod
391
400
  range: span.to_range,
392
401
  severity: Imports::Constant::DiagnosticSeverity::WARNING,
393
402
  source: "klenod",
394
- message: "#{name} is imported but never used",
403
+ message: "`#{name}` is imported but never used",
395
404
  tags: [Imports::Constant::DiagnosticTag::UNNECESSARY]
396
405
  )
397
406
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Klenod
4
4
  module LSP
5
- VERSION = "0.0.30"
5
+ VERSION = "0.0.31"
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.30
4
+ version: 0.0.31
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.30
18
+ version: 0.0.31
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.30
25
+ version: 0.0.31
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: language_server-protocol
28
28
  requirement: !ruby/object:Gem::Requirement