klenod-lsp 0.0.29 → 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 +34 -0
- data/lib/klenod/lsp/check.rb +161 -0
- data/lib/klenod/lsp/cli.rb +39 -9
- data/lib/klenod/lsp/graph_index.rb +13 -7
- data/lib/klenod/lsp/languages/haml/classes.rb +26 -7
- 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
- data/lib/klenod/lsp.rb +1 -0
- metadata +4 -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
|
@@ -43,6 +43,29 @@ bundle exec klenod lsp
|
|
|
43
43
|
The `klenod` command comes from the `klenod` meta gem. The meta gem does not
|
|
44
44
|
install `klenod-lsp` automatically.
|
|
45
45
|
|
|
46
|
+
## Checking a whole project
|
|
47
|
+
|
|
48
|
+
`klenod check` reports the editor's diagnostics for every Ruby, Haml, and CSS
|
|
49
|
+
file under the source directory, like a linter:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
bundle exec klenod check
|
|
53
|
+
bundle exec klenod check src/components src/pages/Home.haml
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
src/pages/Home.haml
|
|
58
|
+
4:3 warning `Card` is imported but never used
|
|
59
|
+
9:12 error Could not resolve "./Missing"
|
|
60
|
+
|
|
61
|
+
1 error, 1 warning, 42 files checked.
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
It collects the whole module graph first, so cross-file diagnostics such as
|
|
65
|
+
unknown props and scoped classes are included. Paths limit which files are
|
|
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.
|
|
68
|
+
|
|
46
69
|
Configure the editor to run that command for `.haml` files. Ruby and CSS files
|
|
47
70
|
can also use it alongside their usual language servers.
|
|
48
71
|
|
|
@@ -90,6 +113,17 @@ exit Klenod::LSP::Server.new(
|
|
|
90
113
|
- `input:` and `output:` — the JSON-RPC streams; default to stdin and stdout.
|
|
91
114
|
- `logger:` — server logging; defaults to stderr.
|
|
92
115
|
|
|
116
|
+
`Klenod::LSP::Check` runs the same checks without a server. It takes the same
|
|
117
|
+
`context:`, `entrypoints:`, and `logger:`:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
results = Klenod::LSP::Check.new(context: context, entrypoints: config.entrypoints).call
|
|
121
|
+
exit Klenod::LSP::Check.report(results, output: $stdout).zero? ? 0 : 1
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`call` accepts absolute file and directory paths to limit the files checked.
|
|
125
|
+
`report` prints each diagnostic and a summary, and returns the diagnostic count.
|
|
126
|
+
|
|
93
127
|
The web example has a complete runner at `example/web/bin/lsp`.
|
|
94
128
|
|
|
95
129
|
## How it works
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "logger"
|
|
5
|
+
|
|
6
|
+
require_relative "documents"
|
|
7
|
+
require_relative "graph_index"
|
|
8
|
+
require_relative "languages"
|
|
9
|
+
require_relative "workspace"
|
|
10
|
+
|
|
11
|
+
module Klenod
|
|
12
|
+
module LSP
|
|
13
|
+
# Reports the language server's diagnostics for every source file at
|
|
14
|
+
# once, for terminals and CI.
|
|
15
|
+
#
|
|
16
|
+
# The graph index collects the whole application first, as the server
|
|
17
|
+
# does in the background, so cross-file diagnostics such as unknown props
|
|
18
|
+
# and scoped classes are complete. Nothing is ever evaluated.
|
|
19
|
+
class Check
|
|
20
|
+
Constant = LanguageServer::Protocol::Constant
|
|
21
|
+
|
|
22
|
+
SEVERITY_NAMES = {
|
|
23
|
+
Constant::DiagnosticSeverity::ERROR => "error",
|
|
24
|
+
Constant::DiagnosticSeverity::WARNING => "warning",
|
|
25
|
+
Constant::DiagnosticSeverity::INFORMATION => "info",
|
|
26
|
+
Constant::DiagnosticSeverity::HINT => "hint"
|
|
27
|
+
}.freeze
|
|
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
|
+
|
|
43
|
+
# One checked file and its diagnostics, which may be empty.
|
|
44
|
+
Result = Data.define(:path, :diagnostics)
|
|
45
|
+
|
|
46
|
+
def initialize(context:, entrypoints: [], logger: nil)
|
|
47
|
+
@workspace = Workspace.new(context: context)
|
|
48
|
+
@index = GraphIndex.new(
|
|
49
|
+
workspace: @workspace,
|
|
50
|
+
entrypoints: entrypoints,
|
|
51
|
+
logger: logger || Logger.new($stderr, progname: "klenod-lsp", level: :warn)
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Checks the source files below `paths`, or all of them when `paths` is
|
|
56
|
+
# empty. Paths are absolute files or directories.
|
|
57
|
+
def call(paths = [])
|
|
58
|
+
Sync do |task|
|
|
59
|
+
@index.start(task)
|
|
60
|
+
@index.wait
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
@index.source_module_ids.filter_map do |module_id|
|
|
64
|
+
path = @workspace.path_for_module_id(module_id)
|
|
65
|
+
next unless selected?(path, paths)
|
|
66
|
+
|
|
67
|
+
document = Document.new(uri: @workspace.uri_for_path(path), path: path, module_id: module_id, text: File.read(path), version: nil)
|
|
68
|
+
language = Languages.for(document)
|
|
69
|
+
next unless language
|
|
70
|
+
|
|
71
|
+
Result.new(path: path, diagnostics: language.diagnostics(@workspace.analyze(module_id, document.text), @workspace, @index))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
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
|
|
91
|
+
.sort_by { |diagnostic| [diagnostic.range.start.line, diagnostic.range.start.character] }
|
|
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]
|
|
97
|
+
end
|
|
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
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
diagnostics = results.flat_map(&:diagnostics)
|
|
119
|
+
output.puts summary(results, diagnostics, paint)
|
|
120
|
+
diagnostics.length
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.summary(results, diagnostics, paint = ->(_name, text) { text })
|
|
124
|
+
files = "#{results.length} #{(results.length == 1) ? "file" : "files"} checked"
|
|
125
|
+
return "#{paint.call(:success, "No problems found")}, #{files}." if diagnostics.empty?
|
|
126
|
+
|
|
127
|
+
counts = diagnostics.group_by(&:severity).sort.map do |severity, group|
|
|
128
|
+
name = SEVERITY_NAMES.fetch(severity)
|
|
129
|
+
paint.call(name.to_sym, "#{group.length} #{(group.length == 1) ? name : "#{name}s"}")
|
|
130
|
+
end
|
|
131
|
+
"#{counts.join(", ")}, #{files}."
|
|
132
|
+
end
|
|
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
|
+
|
|
147
|
+
def self.display_path(path, root)
|
|
148
|
+
relative = Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
|
|
149
|
+
relative.start_with?("../") ? path : relative
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def selected?(path, paths)
|
|
155
|
+
return true if paths.empty?
|
|
156
|
+
|
|
157
|
+
paths.any? { |selected| path == selected || path.start_with?("#{selected}/") }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
data/lib/klenod/lsp/cli.rb
CHANGED
|
@@ -7,24 +7,54 @@ require_relative "../lsp"
|
|
|
7
7
|
module Klenod
|
|
8
8
|
module LSP
|
|
9
9
|
module CLI
|
|
10
|
+
# Loads the nearest klenod.config.rb and yields it from its base
|
|
11
|
+
# directory. Returns 1 when there is no config.
|
|
12
|
+
def self.with_config(output)
|
|
13
|
+
config_path = Klenod::Build::ConfigLoader.find
|
|
14
|
+
unless config_path
|
|
15
|
+
output.puts "Could not find klenod.config.rb"
|
|
16
|
+
return 1
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
config = nil
|
|
20
|
+
Dir.chdir(File.dirname(config_path)) do
|
|
21
|
+
config = Klenod::Build::ConfigLoader.load(config_path)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Dir.chdir(config.base_dir) { yield config }
|
|
25
|
+
end
|
|
26
|
+
|
|
10
27
|
class Command < Samovar::Command
|
|
11
28
|
self.description = "Start a language server for editors."
|
|
12
29
|
|
|
13
30
|
def call
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return 1
|
|
31
|
+
CLI.with_config(output) do |config|
|
|
32
|
+
context = config.context(mode: :development, analysis: true)
|
|
33
|
+
Klenod::LSP::Server.new(context: context, entrypoints: config.entrypoints).start
|
|
18
34
|
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class CheckCommand < Samovar::Command
|
|
39
|
+
self.description = "Report language server diagnostics for every source file."
|
|
19
40
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
41
|
+
many :paths, "Files or directories to check. Defaults to the whole source directory."
|
|
42
|
+
|
|
43
|
+
def call
|
|
44
|
+
root = Dir.pwd
|
|
45
|
+
paths = Array(@paths)
|
|
46
|
+
missing = paths.reject { |path| File.exist?(path) }
|
|
47
|
+
unless missing.empty?
|
|
48
|
+
missing.each { |path| output.puts "No such file or directory: #{path}" }
|
|
49
|
+
return 1
|
|
23
50
|
end
|
|
51
|
+
# The source directory is a real path, so selections must be too.
|
|
52
|
+
paths = paths.map { |path| File.realpath(path) }
|
|
24
53
|
|
|
25
|
-
|
|
54
|
+
CLI.with_config(output) do |config|
|
|
26
55
|
context = config.context(mode: :development, analysis: true)
|
|
27
|
-
Klenod::LSP::
|
|
56
|
+
results = Klenod::LSP::Check.new(context: context, entrypoints: config.entrypoints).call(paths)
|
|
57
|
+
Klenod::LSP::Check.report(results, output: output, root: root).zero? ? 0 : 1
|
|
28
58
|
end
|
|
29
59
|
end
|
|
30
60
|
end
|
|
@@ -125,6 +125,18 @@ module Klenod
|
|
|
125
125
|
end
|
|
126
126
|
end
|
|
127
127
|
|
|
128
|
+
# Every Ruby, Haml, and CSS file under the source directory, skipping
|
|
129
|
+
# dotfiles and dot directories.
|
|
130
|
+
def source_module_ids
|
|
131
|
+
Dir.glob("**/*", base: @workspace.source_dir).sort.filter_map do |relative|
|
|
132
|
+
next if relative.split("/").any? { |segment| segment.start_with?(".") }
|
|
133
|
+
next unless ROOT_EXTENSIONS.include?(File.extname(relative))
|
|
134
|
+
next unless File.file?(File.join(@workspace.source_dir, relative))
|
|
135
|
+
|
|
136
|
+
Klenod::Build::ModuleId.new("app:/#{relative}")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
128
140
|
private
|
|
129
141
|
|
|
130
142
|
def root_module_ids
|
|
@@ -133,13 +145,7 @@ module Klenod
|
|
|
133
145
|
module_id = resolve_entrypoint(specifier)
|
|
134
146
|
roots << module_id if module_id
|
|
135
147
|
end
|
|
136
|
-
|
|
137
|
-
next if relative.split("/").any? { |segment| segment.start_with?(".") }
|
|
138
|
-
next unless ROOT_EXTENSIONS.include?(File.extname(relative))
|
|
139
|
-
next unless File.file?(File.join(@workspace.source_dir, relative))
|
|
140
|
-
|
|
141
|
-
roots << Klenod::Build::ModuleId.new("app:/#{relative}")
|
|
142
|
-
end
|
|
148
|
+
roots.concat(source_module_ids)
|
|
143
149
|
roots.uniq
|
|
144
150
|
end
|
|
145
151
|
|
|
@@ -22,6 +22,7 @@ module Klenod
|
|
|
22
22
|
SHORTHAND_PREFIX = /\A\s*(?:%[A-Za-z][\w:-]*)?(?:[.#][\w-]+)*\.(?<partial>[\w-]*)\z/
|
|
23
23
|
LOOKUP_PREFIX = /ClassNames\[:(?<partial>[\w-]*)\z/
|
|
24
24
|
STYLE_KINDS = %i[companion_style inline_style].freeze
|
|
25
|
+
FILTER_HEAD = /\A\s*:\w/
|
|
25
26
|
|
|
26
27
|
Occurrence = Data.define(:name, :span)
|
|
27
28
|
Definition = Data.define(:module_id, :generated)
|
|
@@ -64,10 +65,14 @@ module Klenod
|
|
|
64
65
|
end
|
|
65
66
|
end
|
|
66
67
|
|
|
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.
|
|
67
71
|
def occurrences(lines)
|
|
72
|
+
filter_body = filter_body_lines(lines)
|
|
68
73
|
lines.each_with_index.flat_map do |line_text, index|
|
|
69
74
|
found = []
|
|
70
|
-
if (head = TAG_HEAD.match(line_text))
|
|
75
|
+
if !filter_body.include?(index) && (head = TAG_HEAD.match(line_text))
|
|
71
76
|
offset = head.begin(:shorthand)
|
|
72
77
|
Text.each_match(head[:shorthand], index, SHORTHAND_CLASS, group: :name) do |match, span|
|
|
73
78
|
found << Occurrence.new(match[:name], span.with(start_character: span.start_character + offset, end_character: span.end_character + offset))
|
|
@@ -79,11 +84,24 @@ module Klenod
|
|
|
79
84
|
end
|
|
80
85
|
|
|
81
86
|
def occurrence_at(lines, position)
|
|
82
|
-
|
|
83
|
-
return nil unless line_text
|
|
87
|
+
return nil unless lines[position.line]
|
|
84
88
|
|
|
85
|
-
occurrences(
|
|
86
|
-
|
|
89
|
+
occurrences(lines).find { |occurrence| occurrence.span.include?(position) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Indexes of the lines inside filters: every line after a `:name`
|
|
93
|
+
# line that is blank or indented deeper than it, as Haml reads them.
|
|
94
|
+
# Indentation alone also works while the document does not parse.
|
|
95
|
+
def filter_body_lines(lines)
|
|
96
|
+
filter_indent = nil
|
|
97
|
+
lines.each_with_index.with_object(Set.new) do |(line_text, index), body|
|
|
98
|
+
indent = line_text[/\A\s*/].length
|
|
99
|
+
if filter_indent && (line_text.strip.empty? || indent > filter_indent)
|
|
100
|
+
body << index
|
|
101
|
+
else
|
|
102
|
+
filter_indent = line_text.match?(FILTER_HEAD) ? indent : nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
87
105
|
end
|
|
88
106
|
|
|
89
107
|
# Warnings for classes the stylesheets do not define. Components
|
|
@@ -99,7 +117,7 @@ module Klenod
|
|
|
99
117
|
range: occurrence.span.to_range,
|
|
100
118
|
severity: Constant::DiagnosticSeverity::WARNING,
|
|
101
119
|
source: "klenod",
|
|
102
|
-
message: "Unknown CSS class
|
|
120
|
+
message: "Unknown CSS class `#{occurrence.name}`: not defined in #{style_names(map, analysis).map { |name| "`#{name}`" }.join(" or ")}"
|
|
103
121
|
)
|
|
104
122
|
end
|
|
105
123
|
end
|
|
@@ -127,7 +145,8 @@ module Klenod
|
|
|
127
145
|
end
|
|
128
146
|
|
|
129
147
|
def completion_items(prefix, position, analysis, workspace, index)
|
|
130
|
-
|
|
148
|
+
in_filter = filter_body_lines(analysis.lines).include?(position.line)
|
|
149
|
+
match = (!in_filter && SHORTHAND_PREFIX.match(prefix)) || LOOKUP_PREFIX.match(prefix)
|
|
131
150
|
return nil unless match
|
|
132
151
|
|
|
133
152
|
map = map(analysis, workspace, index)
|
|
@@ -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
data/lib/klenod/lsp.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
|
|
@@ -48,6 +48,7 @@ files:
|
|
|
48
48
|
- README.md
|
|
49
49
|
- lib/klenod/lsp.rb
|
|
50
50
|
- lib/klenod/lsp/analysis.rb
|
|
51
|
+
- lib/klenod/lsp/check.rb
|
|
51
52
|
- lib/klenod/lsp/cli.rb
|
|
52
53
|
- lib/klenod/lsp/diagnostics.rb
|
|
53
54
|
- lib/klenod/lsp/documents.rb
|