klenod-lsp 0.0.15
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 +7 -0
- data/README.md +114 -0
- data/lib/klenod/lsp/analysis.rb +29 -0
- data/lib/klenod/lsp/cli.rb +33 -0
- data/lib/klenod/lsp/diagnostics.rb +110 -0
- data/lib/klenod/lsp/documents.rb +72 -0
- data/lib/klenod/lsp/graph_index.rb +193 -0
- data/lib/klenod/lsp/languages/css.rb +45 -0
- data/lib/klenod/lsp/languages/haml/classes.rb +202 -0
- data/lib/klenod/lsp/languages/haml/completion.rb +60 -0
- data/lib/klenod/lsp/languages/haml/props.rb +111 -0
- data/lib/klenod/lsp/languages/haml/rename.rb +42 -0
- data/lib/klenod/lsp/languages/haml/ruby_regions.rb +85 -0
- data/lib/klenod/lsp/languages/haml.rb +87 -0
- data/lib/klenod/lsp/languages/imports.rb +428 -0
- data/lib/klenod/lsp/languages/ruby.rb +41 -0
- data/lib/klenod/lsp/languages/syntax.rb +64 -0
- data/lib/klenod/lsp/languages.rb +38 -0
- data/lib/klenod/lsp/renames.rb +141 -0
- data/lib/klenod/lsp/routes.rb +105 -0
- data/lib/klenod/lsp/server.rb +568 -0
- data/lib/klenod/lsp/symbols.rb +177 -0
- data/lib/klenod/lsp/text.rb +78 -0
- data/lib/klenod/lsp/version.rb +7 -0
- data/lib/klenod/lsp/workspace.rb +155 -0
- data/lib/klenod/lsp.rb +9 -0
- metadata +96 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 86a3abc50bc2960a548b3c8a5bf3df849886fdf454127fd004293721410833a2
|
|
4
|
+
data.tar.gz: d769bd57a7a6bfb32db8fdb6d2bf200fdb8db983f9a1f4096819c4ffe9e4b0c0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: dfdb72434f5c00387969e9e76af3fbf40e123c5ec1d17c14281001ff00f8690dce7f5e7fe7c5c889b66ad17cfa47afe575061ad1f0a69b75d2e8f4103129ac2b
|
|
7
|
+
data.tar.gz: 70d80cb81f695470069ee10db6ae3ce38c44c957b218450a9c5a775e27a32ee881f2ef10053020747e294d5049528be29e0c64c05af4c62f7d09b96676f295d2
|
data/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# klenod-lsp
|
|
2
|
+
|
|
3
|
+
Language Server Protocol support for Klenod applications. It uses the configured
|
|
4
|
+
Klenod plugins to analyze Haml, Ruby imports, and CSS without evaluating
|
|
5
|
+
application code.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Diagnostics for Haml, generated Ruby, unresolved imports, unused imports,
|
|
10
|
+
unknown component props, and unknown scoped CSS classes.
|
|
11
|
+
- Definition, hover, document links, and completion for imports and Haml
|
|
12
|
+
component tags.
|
|
13
|
+
- Import navigation for Ruby and for CSS `@import`, `url()`, and
|
|
14
|
+
`composes ... from` references.
|
|
15
|
+
- Find references, document and workspace symbols, and reference-count code
|
|
16
|
+
lenses.
|
|
17
|
+
- Symbol rename within a Haml or Ruby file and import-preserving edits when
|
|
18
|
+
files or directories move.
|
|
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.
|
|
22
|
+
- Route lenses and hovers when `RouterPlugin` is configured.
|
|
23
|
+
|
|
24
|
+
Ruby support is intentionally limited to Klenod imports and their bound
|
|
25
|
+
constants. Use a Ruby language server alongside Klenod for general Ruby
|
|
26
|
+
features.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
Add the gem to the application Gemfile:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
gem "klenod-lsp"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Install it and start the server from a project containing `klenod.config.rb`:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
bundle install
|
|
40
|
+
bundle exec klenod lsp
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The `klenod` command comes from the `klenod` meta gem. The meta gem does not
|
|
44
|
+
install `klenod-lsp` automatically.
|
|
45
|
+
|
|
46
|
+
Configure the editor to run that command for `.haml` files. Ruby and CSS files
|
|
47
|
+
can also use it alongside their usual language servers.
|
|
48
|
+
|
|
49
|
+
For example, with Neovim:
|
|
50
|
+
|
|
51
|
+
```lua
|
|
52
|
+
vim.api.nvim_create_autocmd("FileType", {
|
|
53
|
+
pattern = "haml",
|
|
54
|
+
callback = function()
|
|
55
|
+
vim.lsp.start({
|
|
56
|
+
name = "klenod",
|
|
57
|
+
cmd = { "bundle", "exec", "klenod", "lsp" },
|
|
58
|
+
root_dir = vim.fs.root(0, { "klenod.config.rb", "Gemfile" }),
|
|
59
|
+
})
|
|
60
|
+
end,
|
|
61
|
+
})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
The CLI has no LSP-specific options. It loads the nearest `klenod.config.rb`
|
|
67
|
+
and uses its source directory, plugins, and entrypoints. Position encoding is
|
|
68
|
+
negotiated with the editor.
|
|
69
|
+
|
|
70
|
+
Frameworks that construct their configuration in Ruby can start the server
|
|
71
|
+
directly:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
require "klenod/lsp"
|
|
75
|
+
|
|
76
|
+
config = MyFramework.build_config(mode: :development)
|
|
77
|
+
context = config.context(analysis: true)
|
|
78
|
+
|
|
79
|
+
exit Klenod::LSP::Server.new(
|
|
80
|
+
context: context,
|
|
81
|
+
entrypoints: config.entrypoints
|
|
82
|
+
).start
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`Server.new` accepts:
|
|
86
|
+
|
|
87
|
+
- `context:` — a required `Klenod::Build::Context`; use `analysis: true`.
|
|
88
|
+
- `entrypoints:` — roots collected in addition to source files; defaults to
|
|
89
|
+
none.
|
|
90
|
+
- `input:` and `output:` — the JSON-RPC streams; default to stdin and stdout.
|
|
91
|
+
- `logger:` — server logging; defaults to stderr.
|
|
92
|
+
|
|
93
|
+
The web example has a complete runner at `example/web/bin/lsp`.
|
|
94
|
+
|
|
95
|
+
## How it works
|
|
96
|
+
|
|
97
|
+
Open buffers are transformed with the build plugins for immediate diagnostics.
|
|
98
|
+
A background graph indexes Ruby, Haml, and CSS files for cross-file features,
|
|
99
|
+
including lazy dependencies. Analysis mode skips network fetches, JavaScript
|
|
100
|
+
compilation, image generation, and filesystem output.
|
|
101
|
+
|
|
102
|
+
The editor reports file changes to the server. Clients without dynamic watched
|
|
103
|
+
file registration need to configure a watcher for the application source
|
|
104
|
+
directory.
|
|
105
|
+
|
|
106
|
+
## Limitations
|
|
107
|
+
|
|
108
|
+
- References and rename edits cover application source files. Definition and
|
|
109
|
+
hover can also reach installed gems, while virtual modules have no file
|
|
110
|
+
location.
|
|
111
|
+
- Cross-file results can be incomplete while the initial background index is
|
|
112
|
+
still running.
|
|
113
|
+
- Documents use full-text synchronization, and completion examines only the
|
|
114
|
+
current line up to the cursor.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klenod
|
|
4
|
+
module LSP
|
|
5
|
+
# Everything the server learned from transforming one document's text.
|
|
6
|
+
#
|
|
7
|
+
# `build_error` is the Klenod::Build::Error that stopped the transform, if
|
|
8
|
+
# any. `ruby_errors` are Prism syntax errors in the generated Ruby, and
|
|
9
|
+
# `resolve_errors` are per-dependency ResolveErrors. A document can carry
|
|
10
|
+
# resolved dependencies and errors at the same time.
|
|
11
|
+
Analysis =
|
|
12
|
+
Data.define(:module_id, :source, :transform, :resolved_dependencies, :build_error, :ruby_errors, :resolve_errors) do
|
|
13
|
+
def lines
|
|
14
|
+
source.lines(chomp: true)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def source_map
|
|
18
|
+
transform&.source_map
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolved_module_id_for(specifier)
|
|
22
|
+
resolved = resolved_dependencies.find { |candidate| candidate.dependency.specifier.to_s == specifier }
|
|
23
|
+
resolved&.module_id
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
RubyError = Data.define(:message, :generated_line)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "klenod/build/cli"
|
|
4
|
+
|
|
5
|
+
require_relative "../lsp"
|
|
6
|
+
|
|
7
|
+
module Klenod
|
|
8
|
+
module LSP
|
|
9
|
+
module CLI
|
|
10
|
+
class Command < Samovar::Command
|
|
11
|
+
self.description = "Start a language server for editors."
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
config_path = Klenod::Build::ConfigLoader.find
|
|
15
|
+
unless config_path
|
|
16
|
+
output.puts "Could not find klenod.config.rb"
|
|
17
|
+
return 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
config = nil
|
|
21
|
+
Dir.chdir(File.dirname(config_path)) do
|
|
22
|
+
config = Klenod::Build::ConfigLoader.load(config_path)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Dir.chdir(config.base_dir) do
|
|
26
|
+
context = config.context(mode: :development, analysis: true)
|
|
27
|
+
Klenod::LSP::Server.new(context: context, entrypoints: config.entrypoints).start
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "klenod/build/source_error"
|
|
4
|
+
|
|
5
|
+
require_relative "languages/syntax"
|
|
6
|
+
require_relative "text"
|
|
7
|
+
|
|
8
|
+
module Klenod
|
|
9
|
+
module LSP
|
|
10
|
+
# Turns the errors in an Analysis into LSP diagnostics on the original
|
|
11
|
+
# document. Every diagnostic is an error: Klenod has no warnings yet.
|
|
12
|
+
module Diagnostics
|
|
13
|
+
Interface = LanguageServer::Protocol::Interface
|
|
14
|
+
Constant = LanguageServer::Protocol::Constant
|
|
15
|
+
|
|
16
|
+
SOURCE = "klenod"
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def for_analysis(analysis, syntax: Languages::Syntax::Ruby)
|
|
21
|
+
lines = analysis.lines
|
|
22
|
+
diagnostics = []
|
|
23
|
+
diagnostics << build_error(analysis, lines) if analysis.build_error
|
|
24
|
+
diagnostics.concat(ruby_errors(analysis, lines))
|
|
25
|
+
diagnostics.concat(resolve_errors(analysis, lines, syntax))
|
|
26
|
+
diagnostics
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_error(analysis, lines)
|
|
30
|
+
error = analysis.build_error
|
|
31
|
+
|
|
32
|
+
if error.is_a?(Klenod::Build::SourceError)
|
|
33
|
+
source_error(analysis, error, lines)
|
|
34
|
+
else
|
|
35
|
+
diagnostic(Text.line_span(lines, 0), "#{short_class_name(error)}: #{error.message}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def source_error(analysis, error, lines)
|
|
40
|
+
message = [error.kind, error.detail].compact.join(": ")
|
|
41
|
+
message = "#{message}\n#{error.hints.join("\n")}" unless error.hints.empty?
|
|
42
|
+
|
|
43
|
+
if error.module_id.to_s == analysis.module_id.to_s
|
|
44
|
+
line = error.line ? error.line - 1 : 0
|
|
45
|
+
diagnostic(Text.line_span(lines, line), message)
|
|
46
|
+
else
|
|
47
|
+
diagnostic(Text.line_span(lines, 0), "#{error.module_id}: #{message}")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def ruby_errors(analysis, lines)
|
|
52
|
+
source_map = analysis.source_map
|
|
53
|
+
|
|
54
|
+
analysis.ruby_errors.map { |error|
|
|
55
|
+
original_line = source_map&.find_original_line_no(error.generated_line)
|
|
56
|
+
line = original_line ? original_line - 1 : 0
|
|
57
|
+
diagnostic(Text.line_span(lines, line), "Generated Ruby syntax error: #{error.message}")
|
|
58
|
+
}.uniq { |diagnostic| [diagnostic.range.start.line, diagnostic.message] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resolve_errors(analysis, lines, syntax)
|
|
62
|
+
analysis.resolve_errors.map do |error|
|
|
63
|
+
diagnostic(resolve_error_span(error, lines, syntax), error.message)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Resolve errors know their import's line and column, but for imports
|
|
68
|
+
# outside the leading `:ruby` filter the location points into generated
|
|
69
|
+
# Ruby, so the literal is looked up in the document text first.
|
|
70
|
+
def resolve_error_span(error, lines, syntax = Languages::Syntax::Ruby)
|
|
71
|
+
specifier = error.requested_specifier || error.dependency&.specifier&.to_s
|
|
72
|
+
span = specifier && literal_span(specifier, lines, syntax: syntax)
|
|
73
|
+
return span if span
|
|
74
|
+
|
|
75
|
+
line = error.source_location&.line
|
|
76
|
+
line = line&.between?(1, lines.length) ? line - 1 : 0
|
|
77
|
+
Text.line_span(lines, line)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def literal_span(specifier, lines, syntax: Languages::Syntax::Ruby)
|
|
81
|
+
literal_spans(specifier, lines, syntax: syntax).first
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Every occurrence of a specifier; stylesheets repeat one across
|
|
85
|
+
# `@import` and `composes`.
|
|
86
|
+
def literal_spans(specifier, lines, syntax: Languages::Syntax::Ruby)
|
|
87
|
+
spans = []
|
|
88
|
+
lines.each_with_index do |line_text, index|
|
|
89
|
+
syntax.each_literal(line_text, index) do |literal|
|
|
90
|
+
spans << literal.span if literal.specifier == specifier
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
spans
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def diagnostic(span, message)
|
|
97
|
+
Interface::Diagnostic.new(
|
|
98
|
+
range: span.to_range,
|
|
99
|
+
severity: Constant::DiagnosticSeverity::ERROR,
|
|
100
|
+
source: SOURCE,
|
|
101
|
+
message: message
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def short_class_name(error)
|
|
106
|
+
error.class.name.to_s.split("::").last
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klenod
|
|
4
|
+
module LSP
|
|
5
|
+
Document = Data.define(:uri, :path, :module_id, :text, :version) do
|
|
6
|
+
def lines
|
|
7
|
+
text.lines(chomp: true)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def extname
|
|
11
|
+
path ? File.extname(path) : ""
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Open editor documents, synchronized with full text on every change, and
|
|
16
|
+
# the analysis computed for each document version.
|
|
17
|
+
class Documents
|
|
18
|
+
def initialize(workspace)
|
|
19
|
+
@workspace = workspace
|
|
20
|
+
@documents = {}
|
|
21
|
+
@analyses = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def open(uri:, text:, version:)
|
|
25
|
+
document =
|
|
26
|
+
Document.new(
|
|
27
|
+
uri: uri,
|
|
28
|
+
path: @workspace.path_for_uri(uri),
|
|
29
|
+
module_id: @workspace.module_id_for_uri(uri),
|
|
30
|
+
text: text,
|
|
31
|
+
version: version
|
|
32
|
+
)
|
|
33
|
+
@documents[uri] = document
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def change(uri:, text:, version:)
|
|
37
|
+
existing = @documents[uri]
|
|
38
|
+
return self.open(uri: uri, text: text, version: version) unless existing
|
|
39
|
+
|
|
40
|
+
@documents[uri] = existing.with(text: text, version: version)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def close(uri)
|
|
44
|
+
@analyses.delete(uri)
|
|
45
|
+
@documents.delete(uri)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def fetch(uri)
|
|
49
|
+
@documents[uri]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def each(&)
|
|
53
|
+
@documents.each_value(&)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def invalidate(uri)
|
|
57
|
+
@analyses.delete(uri)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def analysis_for(document)
|
|
61
|
+
cached_analysis(document) || @workspace.analyze(document.module_id, document.text).tap do |analysis|
|
|
62
|
+
@analyses[document.uri] = [document.version, analysis]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def cached_analysis(document)
|
|
67
|
+
cached_version, cached = @analyses[document.uri]
|
|
68
|
+
cached if cached && cached_version == document.version
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/semaphore"
|
|
5
|
+
|
|
6
|
+
require "klenod/build/dependency"
|
|
7
|
+
require "klenod/build/module_id"
|
|
8
|
+
|
|
9
|
+
module Klenod
|
|
10
|
+
module LSP
|
|
11
|
+
# The collected module graph behind cross-file features.
|
|
12
|
+
#
|
|
13
|
+
# Roots are the configured entrypoints plus every Ruby and Haml file under
|
|
14
|
+
# the source directory, so orphan components and test files are indexed
|
|
15
|
+
# too. Lazy dependencies such as router pages are walked as well. Nothing
|
|
16
|
+
# is ever evaluated. Root collections are serialized so two walks cannot
|
|
17
|
+
# deadlock on a shared import cycle; the graph parallelizes inside each.
|
|
18
|
+
class GraphIndex
|
|
19
|
+
ROOT_EXTENSIONS = [".rb", ".haml", ".css"].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :failed
|
|
22
|
+
|
|
23
|
+
def initialize(workspace:, logger:, entrypoints: [])
|
|
24
|
+
@workspace = workspace
|
|
25
|
+
@graph = workspace.context.graph
|
|
26
|
+
@entrypoints = entrypoints
|
|
27
|
+
@logger = logger
|
|
28
|
+
@failed = {}
|
|
29
|
+
@unresolved_entrypoints = []
|
|
30
|
+
@semaphore = Async::Semaphore.new(1)
|
|
31
|
+
@task = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Collect every root in the background, yielding to other work between
|
|
35
|
+
# roots. `progress` responds to `begin(total)`, `report(done, total)`,
|
|
36
|
+
# and `finish`; the block runs once the pass completed.
|
|
37
|
+
def start(parent_task, progress: nil, &on_complete)
|
|
38
|
+
@task =
|
|
39
|
+
parent_task.async do |task|
|
|
40
|
+
roots = root_module_ids
|
|
41
|
+
progress&.begin(roots.length)
|
|
42
|
+
roots.each_with_index do |module_id, index|
|
|
43
|
+
ensure_collected(module_id)
|
|
44
|
+
progress&.report(index + 1, roots.length)
|
|
45
|
+
task.yield
|
|
46
|
+
end
|
|
47
|
+
progress&.finish
|
|
48
|
+
on_complete&.call
|
|
49
|
+
rescue => error
|
|
50
|
+
progress&.finish
|
|
51
|
+
@logger.error { "Graph index failed: #{error.class}: #{error.message}" }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def stop
|
|
56
|
+
@task&.stop
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def running?
|
|
60
|
+
@task ? !@task.finished? : false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def wait
|
|
64
|
+
@task&.wait
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def records
|
|
68
|
+
@graph.records
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def record(module_id)
|
|
72
|
+
@graph.records[module_id]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def dependents(module_id)
|
|
76
|
+
@graph.dependents(module_id)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Collect one module and everything reachable from it, remembering
|
|
80
|
+
# failures so they can be retried when files change.
|
|
81
|
+
def ensure_collected(module_id, force: false)
|
|
82
|
+
@semaphore.acquire { collect_root(module_id, force: force) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Apply file changes through the build's own invalidation, collect new
|
|
86
|
+
# modules, retry earlier failures, and return the ids whose records may
|
|
87
|
+
# have changed, including their dependents.
|
|
88
|
+
def invalidate(changed_paths, removed_paths)
|
|
89
|
+
@semaphore.acquire do
|
|
90
|
+
result = @workspace.context.invalidate_paths(changed_paths, removed_paths: removed_paths)
|
|
91
|
+
affected = Set.new
|
|
92
|
+
[result.changed_module_ids, result.removed_module_ids, result.reloaded_module_ids, result.reevaluated_module_ids].each do |ids|
|
|
93
|
+
ids.each { |module_id| affected << module_id.to_s }
|
|
94
|
+
end
|
|
95
|
+
result.errors.each do |module_id, error|
|
|
96
|
+
next unless module_id
|
|
97
|
+
|
|
98
|
+
@failed[module_id.to_s] = error
|
|
99
|
+
affected << module_id.to_s
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
changed_paths.each do |path|
|
|
103
|
+
module_id = @workspace.module_id_for_path(path)
|
|
104
|
+
next unless module_id && ROOT_EXTENSIONS.include?(module_id.extname)
|
|
105
|
+
next if @graph.records.key?(module_id)
|
|
106
|
+
|
|
107
|
+
collect_root(module_id)
|
|
108
|
+
affected << module_id.to_s
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A retried module is affected whether or not it recovers: its
|
|
112
|
+
# diagnostics changed either way. The retry forces a re-collect,
|
|
113
|
+
# because a module that failed after its record was stored still has
|
|
114
|
+
# that stale record.
|
|
115
|
+
@failed.keys.each do |module_id_string|
|
|
116
|
+
collect_root(Klenod::Build::ModuleId.new(module_id_string), force: true)
|
|
117
|
+
affected << module_id_string
|
|
118
|
+
end
|
|
119
|
+
@unresolved_entrypoints.dup.each do |specifier|
|
|
120
|
+
module_id = resolve_entrypoint(specifier)
|
|
121
|
+
affected << module_id.to_s if module_id && collect_root(module_id)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
dependents_closure(affected)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def root_module_ids
|
|
131
|
+
roots = []
|
|
132
|
+
@entrypoints.each do |specifier|
|
|
133
|
+
module_id = resolve_entrypoint(specifier)
|
|
134
|
+
roots << module_id if module_id
|
|
135
|
+
end
|
|
136
|
+
Dir.glob("**/*", base: @workspace.source_dir).sort.each do |relative|
|
|
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
|
|
143
|
+
roots.uniq
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def resolve_entrypoint(specifier)
|
|
147
|
+
dependency = Klenod::Build::Dependency.create(specifier: specifier, importer_id: nil, kind: :entrypoint)
|
|
148
|
+
module_id = @graph.resolve_dependency(dependency).module_id
|
|
149
|
+
@unresolved_entrypoints.delete(specifier)
|
|
150
|
+
module_id
|
|
151
|
+
rescue Klenod::Build::ResolveError => error
|
|
152
|
+
@unresolved_entrypoints << specifier unless @unresolved_entrypoints.include?(specifier)
|
|
153
|
+
@logger.warn { "Entrypoint #{specifier.inspect} did not resolve: #{error.message}" }
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Returns true when the root is collected, false when it failed.
|
|
158
|
+
def collect_root(module_id, force: false)
|
|
159
|
+
if force
|
|
160
|
+
@graph.collect_module(module_id, force: true)
|
|
161
|
+
else
|
|
162
|
+
@graph.records[module_id] || @graph.collect_module(module_id)
|
|
163
|
+
end
|
|
164
|
+
@failed.delete(module_id.to_s)
|
|
165
|
+
@graph.collect_reachable(module_id) do |reached_id, error|
|
|
166
|
+
if error
|
|
167
|
+
@failed[reached_id.to_s] = error
|
|
168
|
+
else
|
|
169
|
+
@failed.delete(reached_id.to_s)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
true
|
|
173
|
+
rescue StandardError, ScriptError => error
|
|
174
|
+
@failed[module_id.to_s] = error
|
|
175
|
+
false
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def dependents_closure(module_id_strings)
|
|
179
|
+
seen = Set.new(module_id_strings)
|
|
180
|
+
queue = module_id_strings.to_a
|
|
181
|
+
|
|
182
|
+
until queue.empty?
|
|
183
|
+
module_id_string = queue.shift
|
|
184
|
+
@graph.dependents(Klenod::Build::ModuleId.new(module_id_string)).each do |dependent_id|
|
|
185
|
+
queue << dependent_id.to_s if seen.add?(dependent_id.to_s)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
seen
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../diagnostics"
|
|
4
|
+
require_relative "imports"
|
|
5
|
+
require_relative "syntax"
|
|
6
|
+
|
|
7
|
+
module Klenod
|
|
8
|
+
module LSP
|
|
9
|
+
module Languages
|
|
10
|
+
# Stylesheets under the source directory: build diagnostics plus
|
|
11
|
+
# navigation and completion for `@import`, `url()`, and `composes ...
|
|
12
|
+
# from` references, resolved the way the CSS plugin resolves them.
|
|
13
|
+
class CSS
|
|
14
|
+
include ImportNavigation
|
|
15
|
+
|
|
16
|
+
Interface = LanguageServer::Protocol::Interface
|
|
17
|
+
|
|
18
|
+
def syntax
|
|
19
|
+
Syntax::CSS
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def diagnostics(analysis, _workspace = nil, _index = nil)
|
|
23
|
+
Diagnostics.for_analysis(analysis, syntax: syntax)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def completion(analysis, position, workspace, _index = nil)
|
|
27
|
+
line_text = analysis.lines[position.line]
|
|
28
|
+
return nil unless line_text
|
|
29
|
+
|
|
30
|
+
items = Imports.completion_items(line_text[0...position.character].to_s, position, analysis, workspace, syntax: syntax)
|
|
31
|
+
items && Interface::CompletionList.new(is_incomplete: false, items: items)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def document_symbols(_analysis)
|
|
35
|
+
[]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def target_at(analysis, position)
|
|
39
|
+
line_text = analysis.lines[position.line]
|
|
40
|
+
line_text && Imports.target_at(line_text, position, syntax: syntax)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|