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 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