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 +4 -4
- data/README.md +5 -2
- data/lib/klenod/lsp/check.rb +71 -17
- data/lib/klenod/lsp/languages/haml/classes.rb +1 -1
- data/lib/klenod/lsp/languages/haml/props.rb +20 -10
- data/lib/klenod/lsp/languages/haml/ruby_regions.rb +33 -4
- data/lib/klenod/lsp/languages/imports.rb +10 -1
- data/lib/klenod/lsp/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0d5602eca5221374939807c3f33d43a9ac7794453a73b049e579a77ac229b551
|
|
4
|
+
data.tar.gz: 8dd3d801a3a62fc2405b84a221afef3a8ca9b2577a0a33bc3ac73a3a26579651
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
58
|
-
|
|
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.
|
data/lib/klenod/lsp/check.rb
CHANGED
|
@@ -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
|
|
62
|
-
# with paths relative to `root`, then a summary
|
|
63
|
-
#
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
17
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
65
|
-
#
|
|
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
|
|
98
|
-
message += "; did you mean
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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: "
|
|
403
|
+
message: "`#{name}` is imported but never used",
|
|
395
404
|
tags: [Imports::Constant::DiagnosticTag::UNNECESSARY]
|
|
396
405
|
)
|
|
397
406
|
end
|
data/lib/klenod/lsp/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
25
|
+
version: 0.0.31
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: language_server-protocol
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|