klenod-lsp 0.0.29 → 0.0.30
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 +31 -0
- data/lib/klenod/lsp/check.rb +107 -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 +25 -6
- 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: 4e64b40c339a0f3b2e2338120f4fce1a631202dad834e0ca30566048db1f5f0f
|
|
4
|
+
data.tar.gz: 5458d0f7574457c17214c08e84db53ecb14677fa75f89dffe229de1f1dac8543
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a448b50f1d21c57317f13893c48ee376560cd4324900b2d8fbd1ab841c7d849c6f4d1be99237e4f7893149dafbeb15137ab997220c5ad6c968fb8245ff627e4a
|
|
7
|
+
data.tar.gz: 9093c27e1d87afee0d355d121371aa9c37ec555a03e1e9090dc52ee4a0e18f619406b2d8de7f83dfd81d8d0679991af3546f9c5865e5eccd6a04c982bb5eeea8
|
data/README.md
CHANGED
|
@@ -43,6 +43,26 @@ 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:4:3: warning: Card is imported but never used
|
|
58
|
+
src/pages/Home.haml:9:12: error: Could not resolve "./Missing"
|
|
59
|
+
1 error, 1 warning, 42 files checked.
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
It collects the whole module graph first, so cross-file diagnostics such as
|
|
63
|
+
unknown props and scoped classes are included. Paths limit which files are
|
|
64
|
+
reported. The command exits with status 1 when it reports any diagnostic.
|
|
65
|
+
|
|
46
66
|
Configure the editor to run that command for `.haml` files. Ruby and CSS files
|
|
47
67
|
can also use it alongside their usual language servers.
|
|
48
68
|
|
|
@@ -90,6 +110,17 @@ exit Klenod::LSP::Server.new(
|
|
|
90
110
|
- `input:` and `output:` — the JSON-RPC streams; default to stdin and stdout.
|
|
91
111
|
- `logger:` — server logging; defaults to stderr.
|
|
92
112
|
|
|
113
|
+
`Klenod::LSP::Check` runs the same checks without a server. It takes the same
|
|
114
|
+
`context:`, `entrypoints:`, and `logger:`:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
results = Klenod::LSP::Check.new(context: context, entrypoints: config.entrypoints).call
|
|
118
|
+
exit Klenod::LSP::Check.report(results, output: $stdout).zero? ? 0 : 1
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`call` accepts absolute file and directory paths to limit the files checked.
|
|
122
|
+
`report` prints each diagnostic and a summary, and returns the diagnostic count.
|
|
123
|
+
|
|
93
124
|
The web example has a complete runner at `example/web/bin/lsp`.
|
|
94
125
|
|
|
95
126
|
## How it works
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
# One checked file and its diagnostics, which may be empty.
|
|
30
|
+
Result = Data.define(:path, :diagnostics)
|
|
31
|
+
|
|
32
|
+
def initialize(context:, entrypoints: [], logger: nil)
|
|
33
|
+
@workspace = Workspace.new(context: context)
|
|
34
|
+
@index = GraphIndex.new(
|
|
35
|
+
workspace: @workspace,
|
|
36
|
+
entrypoints: entrypoints,
|
|
37
|
+
logger: logger || Logger.new($stderr, progname: "klenod-lsp", level: :warn)
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Checks the source files below `paths`, or all of them when `paths` is
|
|
42
|
+
# empty. Paths are absolute files or directories.
|
|
43
|
+
def call(paths = [])
|
|
44
|
+
Sync do |task|
|
|
45
|
+
@index.start(task)
|
|
46
|
+
@index.wait
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@index.source_module_ids.filter_map do |module_id|
|
|
50
|
+
path = @workspace.path_for_module_id(module_id)
|
|
51
|
+
next unless selected?(path, paths)
|
|
52
|
+
|
|
53
|
+
document = Document.new(uri: @workspace.uri_for_path(path), path: path, module_id: module_id, text: File.read(path), version: nil)
|
|
54
|
+
language = Languages.for(document)
|
|
55
|
+
next unless language
|
|
56
|
+
|
|
57
|
+
Result.new(path: path, diagnostics: language.diagnostics(@workspace.analyze(module_id, document.text), @workspace, @index))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
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
|
|
67
|
+
.sort_by { |diagnostic| [diagnostic.range.start.line, diagnostic.range.start.character] }
|
|
68
|
+
.map { |diagnostic| [display_path(result.path, root), diagnostic] }
|
|
69
|
+
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}" }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
output.puts summary(results, diagnostics.map(&:last))
|
|
79
|
+
diagnostics.length
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.summary(results, diagnostics)
|
|
83
|
+
files = "#{results.length} #{(results.length == 1) ? "file" : "files"} checked"
|
|
84
|
+
return "No problems found, #{files}." if diagnostics.empty?
|
|
85
|
+
|
|
86
|
+
counts = diagnostics.group_by(&:severity).sort.map do |severity, group|
|
|
87
|
+
name = SEVERITY_NAMES.fetch(severity)
|
|
88
|
+
"#{group.length} #{(group.length == 1) ? name : "#{name}s"}"
|
|
89
|
+
end
|
|
90
|
+
"#{counts.join(", ")}, #{files}."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.display_path(path, root)
|
|
94
|
+
relative = Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
|
|
95
|
+
relative.start_with?("../") ? path : relative
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def selected?(path, paths)
|
|
101
|
+
return true if paths.empty?
|
|
102
|
+
|
|
103
|
+
paths.any? { |selected| path == selected || path.start_with?("#{selected}/") }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
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
|
|
@@ -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)
|
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.30
|
|
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.30
|
|
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.30
|
|
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
|