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.
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "language_server-protocol"
4
+
5
+ require "klenod/build/plugins/haml_plugin"
6
+
7
+ require_relative "languages/imports"
8
+ require_relative "text"
9
+
10
+ module Klenod
11
+ module LSP
12
+ # Outlines for documents and name search across the indexed graph.
13
+ module Symbols
14
+ Interface = LanguageServer::Protocol::Interface
15
+ Constant = LanguageServer::Protocol::Constant
16
+
17
+ COMPONENT_TAG = /%(?<name>[A-Z][A-Za-z0-9_:]*)/
18
+ ELEMENT_TAG = /%(?<name>[a-z][A-Za-z0-9_-]*)/
19
+ METHOD = /\A\s*def\s+(?<name>(?:self\.)?[A-Za-z_][A-Za-z0-9_]*[?!=]?)/
20
+ WORKSPACE_LIMIT = 200
21
+
22
+ module_function
23
+
24
+ # The outline of a Haml component: constants and methods from the
25
+ # leading `:ruby` filter, then the tag tree. Script lines such as
26
+ # `- if` are transparent, so their children appear at the same level.
27
+ def haml_document_symbols(source)
28
+ lines = source.lines(chomp: true)
29
+ root = Klenod::Build::Plugins::HamlPlugin.parse_haml(source)
30
+ symbols_for_nodes(root.children, lines)
31
+ rescue Klenod::Build::Error
32
+ []
33
+ end
34
+
35
+ # Constants bound to imports, for Ruby modules.
36
+ def binding_symbols(source)
37
+ lines = source.lines(chomp: true)
38
+ symbols = []
39
+ lines.each_with_index do |line_text, index|
40
+ Text.each_match(line_text, index, Languages::Imports::BINDING, group: :name) do |match, span|
41
+ symbols << symbol(match[:name], Constant::SymbolKind::CONSTANT, Text.line_span(lines, index), span, detail: match[:specifier])
42
+ end
43
+ end
44
+ symbols
45
+ end
46
+
47
+ # Modules under the source directory whose name or path matches the
48
+ # query, as SymbolInformation so every client can show them.
49
+ def workspace_symbols(query, index, workspace)
50
+ needle = query.to_s.downcase
51
+ matches = []
52
+
53
+ index.records.each_key do |module_id|
54
+ next unless module_id.scheme == :app && GraphIndex::ROOT_EXTENSIONS.include?(module_id.extname)
55
+
56
+ relative = module_id.relative_path
57
+ stylesheet = module_id.extname == ".css"
58
+ name = stylesheet ? File.basename(relative) : File.basename(relative, module_id.extname)
59
+ next unless needle.empty? || fuzzy_match?(name.downcase, needle) || relative.downcase.include?(needle)
60
+
61
+ uri = workspace.uri_for_module_id(module_id)
62
+ next unless uri
63
+
64
+ matches << Interface::SymbolInformation.new(
65
+ name: name,
66
+ kind: symbol_kind(module_id),
67
+ location: Interface::Location.new(uri: uri, range: Text.zero_range),
68
+ container_name: File.dirname(relative)
69
+ )
70
+ end
71
+
72
+ matches.sort_by { |symbol| [symbol.name.downcase, symbol.container_name] }.first(WORKSPACE_LIMIT)
73
+ end
74
+
75
+ def symbol_kind(module_id)
76
+ case module_id.extname
77
+ when ".haml" then Constant::SymbolKind::CLASS
78
+ when ".css" then Constant::SymbolKind::FILE
79
+ else Constant::SymbolKind::MODULE
80
+ end
81
+ end
82
+
83
+ def symbols_for_nodes(nodes, lines)
84
+ nodes.flat_map do |node|
85
+ case node.type
86
+ when :tag
87
+ [tag_symbol(node, lines)]
88
+ when :filter
89
+ (node.value[:name] == "ruby") ? ruby_filter_symbols(node, lines) : []
90
+ when :script, :silent_script
91
+ symbols_for_nodes(node.children, lines)
92
+ else
93
+ []
94
+ end
95
+ end
96
+ end
97
+
98
+ def tag_symbol(node, lines)
99
+ name = node.value.fetch(:name)
100
+ line_index = node.line - 1
101
+ component = name.match?(/\A[A-Z]/)
102
+ span = tag_span(lines[line_index].to_s, line_index, component ? COMPONENT_TAG : ELEMENT_TAG, name)
103
+
104
+ symbol(
105
+ "%#{name}",
106
+ component ? Constant::SymbolKind::CLASS : Constant::SymbolKind::FIELD,
107
+ Text::Span.new(line_index, 0, 0).with(line: line_index),
108
+ span,
109
+ range_end: last_line(node),
110
+ children: symbols_for_nodes(node.children, lines),
111
+ lines: lines
112
+ )
113
+ end
114
+
115
+ def ruby_filter_symbols(node, lines)
116
+ symbols = []
117
+ node.value.fetch(:text).to_s.each_line.with_index(node.line) do |text, line_index|
118
+ if (match = Languages::Imports::BINDING.match(text))
119
+ Text.each_match(lines[line_index].to_s, line_index, Languages::Imports::BINDING, group: :name) do |_match, span|
120
+ symbols << symbol(match[:name], Constant::SymbolKind::CONSTANT, Text.line_span(lines, line_index), span, detail: match[:specifier])
121
+ end
122
+ elsif (match = METHOD.match(text))
123
+ Text.each_match(lines[line_index].to_s, line_index, METHOD, group: :name) do |_match, span|
124
+ symbols << symbol(match[:name], Constant::SymbolKind::METHOD, Text.line_span(lines, line_index), span)
125
+ end
126
+ end
127
+ end
128
+ symbols
129
+ end
130
+
131
+ def tag_span(line_text, line_index, pattern, name)
132
+ Text.each_match(line_text, line_index, pattern, group: :name) do |match, span|
133
+ return span if match[:name] == name
134
+ end
135
+
136
+ Text.line_span([line_text], 0).with(line: line_index)
137
+ end
138
+
139
+ def last_line(node)
140
+ [node.line, *node.children.map { |child| last_line(child) }].max
141
+ end
142
+
143
+ def symbol(name, kind, full_span, selection_span, detail: nil, range_end: nil, children: nil, lines: nil)
144
+ range =
145
+ if range_end
146
+ end_index = range_end - 1
147
+ Interface::Range.new(
148
+ start: Interface::Position.new(line: full_span.line, character: 0),
149
+ end: Interface::Position.new(line: end_index, character: lines[end_index].to_s.length)
150
+ )
151
+ else
152
+ full_span.to_range
153
+ end
154
+
155
+ Interface::DocumentSymbol.new(
156
+ name: name,
157
+ detail: detail,
158
+ kind: kind,
159
+ range: range,
160
+ selection_range: selection_span.to_range,
161
+ children: (children.nil? || children.empty?) ? nil : children
162
+ )
163
+ end
164
+
165
+ def fuzzy_match?(name, needle)
166
+ position = 0
167
+ needle.each_char do |char|
168
+ position = name.index(char, position)
169
+ return false unless position
170
+
171
+ position += 1
172
+ end
173
+ true
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "language_server-protocol"
4
+
5
+ module Klenod
6
+ module LSP
7
+ # Line and character arithmetic shared by every language handler.
8
+ #
9
+ # Klenod's parsers report lines but not columns, so character ranges are
10
+ # recovered by scanning the original line text. Characters are counted as
11
+ # Ruby characters, which matches LSP's UTF-16 encoding for every character
12
+ # in the Basic Multilingual Plane.
13
+ module Text
14
+ Interface = LanguageServer::Protocol::Interface
15
+
16
+ Position = Data.define(:line, :character)
17
+
18
+ Span = Data.define(:line, :start_character, :end_character) do
19
+ # Cursor positions directly after the last character count as inside,
20
+ # which is where an editor places the caret at the end of a word.
21
+ def include?(position)
22
+ position.line == line && position.character.between?(start_character, end_character)
23
+ end
24
+
25
+ def to_range
26
+ Interface::Range.new(
27
+ start: Interface::Position.new(line: line, character: start_character),
28
+ end: Interface::Position.new(line: line, character: end_character)
29
+ )
30
+ end
31
+ end
32
+
33
+ module_function
34
+
35
+ def each_match(line_text, line_index, regex, group:)
36
+ offset = 0
37
+ while (match = regex.match(line_text, offset))
38
+ start_character = match.begin(group)
39
+ end_character = match.end(group)
40
+ yield match, Span.new(line_index, start_character, end_character)
41
+ offset = [match.end(0), offset + 1].max
42
+ end
43
+ end
44
+
45
+ def line_span(lines, line_index)
46
+ line_index = line_index.clamp(0, [lines.length - 1, 0].max)
47
+ Span.new(line_index, 0, lines[line_index]&.length || 0)
48
+ end
49
+
50
+ def zero_range
51
+ Span.new(0, 0, 0).to_range
52
+ end
53
+
54
+ def protocol_character(line_text, character, encoding)
55
+ prefix = line_text.each_char.first(character).join
56
+ case encoding
57
+ when "utf-8" then prefix.bytesize
58
+ when "utf-16" then prefix.encode(Encoding::UTF_16LE).bytesize / 2
59
+ else character
60
+ end
61
+ end
62
+
63
+ def ruby_character(line_text, character, encoding)
64
+ return character if encoding == "utf-32"
65
+
66
+ units = 0
67
+ line_text.each_char.with_index do |char, index|
68
+ width = (encoding == "utf-8") ? char.bytesize : char.encode(Encoding::UTF_16LE).bytesize / 2
69
+ return index if units + width > character
70
+
71
+ units += width
72
+ return index + 1 if units == character
73
+ end
74
+ line_text.length
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klenod
4
+ module LSP
5
+ VERSION = "0.0.15"
6
+ end
7
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require "klenod/build/context"
6
+
7
+ require_relative "analysis"
8
+ require_relative "routes"
9
+
10
+ module Klenod
11
+ module LSP
12
+ # Adapts a Klenod::Build::Context to editor documents.
13
+ #
14
+ # The workspace only transforms and resolves; it never collects records
15
+ # or evaluates application code. That keeps unsaved editor text out of
16
+ # the graph and keeps the server responsive without a file watcher.
17
+ class Workspace
18
+ IMPORT_KIND = :haml_import
19
+
20
+ attr_reader :context, :source_dir
21
+
22
+ def initialize(context:)
23
+ @context = context
24
+ @graph = context.graph
25
+ @source_dir = File.expand_path(@graph.source_dir.to_s)
26
+ @routes = Routes.new(self)
27
+ end
28
+
29
+ attr_reader :routes
30
+
31
+ def path_for_uri(uri)
32
+ return nil unless uri.start_with?("file://")
33
+
34
+ path = uri.delete_prefix("file://").split("?", 2).first.to_s
35
+ path = path.sub(/\A[^\/]+/, "") unless path.start_with?("/")
36
+ percent_decode(path)
37
+ end
38
+
39
+ def uri_for_path(path)
40
+ "file://#{path.split("/", -1).map { |segment| percent_encode(segment) }.join("/")}"
41
+ end
42
+
43
+ def module_id_for_uri(uri)
44
+ path = path_for_uri(uri)
45
+ path && module_id_for_path(path)
46
+ end
47
+
48
+ def module_id_for_path(path)
49
+ return nil unless path.start_with?("#{source_dir}/")
50
+
51
+ Klenod::Build::ModuleId.new("app:/#{path.delete_prefix("#{source_dir}/")}")
52
+ end
53
+
54
+ # App modules map through the resolver; gem modules through the gem
55
+ # import plugin, which reports the file in its resolution metadata.
56
+ def path_for_module_id(module_id)
57
+ case module_id.scheme
58
+ when :app
59
+ @graph.absolute_path(module_id).to_s
60
+ when :gem
61
+ dependency = Klenod::Build::Dependency.create(specifier: module_id.to_s, importer_id: nil, kind: IMPORT_KIND)
62
+ @graph.resolve_dependency(dependency).metadata[:path]&.to_s
63
+ end
64
+ rescue Klenod::Build::ResolveError
65
+ nil
66
+ end
67
+
68
+ def uri_for_module_id(module_id)
69
+ path = path_for_module_id(module_id)
70
+ path && uri_for_path(path)
71
+ end
72
+
73
+ # The Haml plugin's `variables` mapping, e.g. `{global: "@__props"}`.
74
+ # Empty when no Haml plugin is configured or it maps nothing.
75
+ def haml_variables
76
+ plugin = @graph.plugins.find { |candidate| candidate.is_a?(Klenod::Build::Plugins::HamlPlugin::Plugin) }
77
+ plugin&.variables || {}
78
+ end
79
+
80
+ def analyze(module_id, source)
81
+ transform = @graph.transform_source(module_id, source)
82
+ resolved_dependencies, resolve_errors = resolve_dependencies(module_id, transform)
83
+
84
+ Analysis.new(
85
+ module_id: module_id,
86
+ source: source,
87
+ transform: transform,
88
+ resolved_dependencies: resolved_dependencies,
89
+ build_error: nil,
90
+ ruby_errors: ruby_errors_for(transform),
91
+ resolve_errors: resolve_errors
92
+ )
93
+ rescue Klenod::Build::Error => error
94
+ failed_analysis(module_id, source, error)
95
+ rescue => error
96
+ # Plugins can raise their parser's own errors on half-typed source,
97
+ # e.g. SyntaxTree on an unterminated import string. Report them
98
+ # rather than dropping the document's diagnostics.
99
+ failed_analysis(module_id, source, error)
100
+ end
101
+
102
+ def resolve(specifier, importer_id:, kind: IMPORT_KIND)
103
+ dependency = Klenod::Build::Dependency.create(specifier: specifier, importer_id: importer_id, kind: kind)
104
+ @graph.resolve_dependency(dependency).module_id
105
+ rescue Klenod::Build::ResolveError
106
+ nil
107
+ end
108
+
109
+ private
110
+
111
+ def failed_analysis(module_id, source, error)
112
+ Analysis.new(
113
+ module_id: module_id,
114
+ source: source,
115
+ transform: nil,
116
+ resolved_dependencies: [],
117
+ build_error: error,
118
+ ruby_errors: [],
119
+ resolve_errors: []
120
+ )
121
+ end
122
+
123
+ def resolve_dependencies(module_id, transform)
124
+ resolved = []
125
+ errors = []
126
+
127
+ transform.dependencies.each do |dependency|
128
+ resolved << @graph.resolve_dependency(dependency)
129
+ rescue Klenod::Build::ResolveError => error
130
+ errors << error.with_resolution_context(dependency: dependency, importer_id: module_id)
131
+ end
132
+
133
+ [resolved, errors]
134
+ end
135
+
136
+ # Ruby modules are checked against their original source by RubyPlugin,
137
+ # which reports better locations than the generated code would.
138
+ def ruby_errors_for(transform)
139
+ return [] if transform.code.nil?
140
+
141
+ Prism.parse(transform.code).errors.map do |error|
142
+ RubyError.new(message: error.message, generated_line: error.location.start_line)
143
+ end
144
+ end
145
+
146
+ def percent_decode(value)
147
+ value.gsub(/%[0-9A-Fa-f]{2}/) { |escaped| escaped[1..].hex.chr }.force_encoding(Encoding::UTF_8)
148
+ end
149
+
150
+ def percent_encode(segment)
151
+ segment.b.gsub(/[^A-Za-z0-9\-._~!$&'()*+,;=:@]/) { |byte| format("%%%02X", byte.ord) }
152
+ end
153
+ end
154
+ end
155
+ end
data/lib/klenod/lsp.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lsp/version"
4
+ require_relative "lsp/server"
5
+
6
+ module Klenod
7
+ module LSP
8
+ end
9
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: klenod-lsp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.15
5
+ platform: ruby
6
+ authors:
7
+ - Andrés Alin
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: klenod-build
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 0.0.15
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 0.0.15
26
+ - !ruby/object:Gem::Dependency
27
+ name: language_server-protocol
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.17'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.17'
40
+ description: Editor diagnostics and navigation for Klenod Haml modules over the Language
41
+ Server Protocol.
42
+ email:
43
+ - andreas.alin@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - README.md
49
+ - lib/klenod/lsp.rb
50
+ - lib/klenod/lsp/analysis.rb
51
+ - lib/klenod/lsp/cli.rb
52
+ - lib/klenod/lsp/diagnostics.rb
53
+ - lib/klenod/lsp/documents.rb
54
+ - lib/klenod/lsp/graph_index.rb
55
+ - lib/klenod/lsp/languages.rb
56
+ - lib/klenod/lsp/languages/css.rb
57
+ - lib/klenod/lsp/languages/haml.rb
58
+ - lib/klenod/lsp/languages/haml/classes.rb
59
+ - lib/klenod/lsp/languages/haml/completion.rb
60
+ - lib/klenod/lsp/languages/haml/props.rb
61
+ - lib/klenod/lsp/languages/haml/rename.rb
62
+ - lib/klenod/lsp/languages/haml/ruby_regions.rb
63
+ - lib/klenod/lsp/languages/imports.rb
64
+ - lib/klenod/lsp/languages/ruby.rb
65
+ - lib/klenod/lsp/languages/syntax.rb
66
+ - lib/klenod/lsp/renames.rb
67
+ - lib/klenod/lsp/routes.rb
68
+ - lib/klenod/lsp/server.rb
69
+ - lib/klenod/lsp/symbols.rb
70
+ - lib/klenod/lsp/text.rb
71
+ - lib/klenod/lsp/version.rb
72
+ - lib/klenod/lsp/workspace.rb
73
+ homepage: https://github.com/aalin/klenod
74
+ licenses:
75
+ - MIT
76
+ metadata:
77
+ homepage_uri: https://github.com/aalin/klenod
78
+ source_code_uri: https://github.com/aalin/klenod/tree/main/gems/klenod-lsp
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '4.0'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 4.0.16
94
+ specification_version: 4
95
+ summary: Language server for Klenod applications.
96
+ test_files: []