kapusta 0.7.0 → 0.8.0

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,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative '../reader'
5
+ require_relative 'scope_walker'
6
+
7
+ module Kapusta
8
+ class LSP
9
+ class WorkspaceIndex
10
+ Entry = Struct.new(:uri, :text, :forms, :walker, keyword_init: true)
11
+
12
+ MACRO_MODULE_EXTENSIONS = %w[kapm kap].freeze
13
+ SCAN_EXTENSIONS = %w[kap kapm].freeze
14
+
15
+ def initialize(roots: [])
16
+ @roots = Array(roots)
17
+ @entries = {}
18
+ end
19
+
20
+ def scan!
21
+ @roots.each do |root|
22
+ SCAN_EXTENSIONS.each do |ext|
23
+ Dir.glob(File.join(root, '**', "*.#{ext}")).each do |path|
24
+ uri = path_to_uri(path)
25
+ text = File.read(path)
26
+ store(uri, text)
27
+ end
28
+ end
29
+ end
30
+ self
31
+ end
32
+
33
+ def refresh(uri, text)
34
+ store(uri, text)
35
+ end
36
+
37
+ def remove(uri)
38
+ path = uri_to_path(uri)
39
+ if path && File.file?(path)
40
+ store(uri, File.read(path))
41
+ else
42
+ @entries.delete(uri)
43
+ end
44
+ end
45
+
46
+ def entry(uri)
47
+ @entries[uri]
48
+ end
49
+
50
+ def entry_count
51
+ @entries.length
52
+ end
53
+
54
+ def toplevel_fn_definitions(name)
55
+ result = []
56
+ @entries.each do |uri, entry|
57
+ entry.walker.bindings.each do |b|
58
+ result << [uri, b] if b.kind == :toplevel_fn && b.name == name
59
+ end
60
+ end
61
+ result
62
+ end
63
+
64
+ def constant_definitions_with_prefix(prefix)
65
+ result = []
66
+ @entries.each do |uri, entry|
67
+ entry.walker.bindings.each do |b|
68
+ next unless %i[module class].include?(b.kind)
69
+
70
+ segs = b.sym.dotted? ? b.sym.segments : [b.sym.name]
71
+ result << [uri, b] if segs == prefix
72
+ end
73
+ end
74
+ result
75
+ end
76
+
77
+ def toplevel_fn_occurrences(name)
78
+ result = {}
79
+ @entries.each do |uri, entry|
80
+ occs = entry.walker.bindings.select do |b|
81
+ b.kind == :toplevel_fn && b.name == name
82
+ end
83
+ occs += entry.walker.references.select do |r|
84
+ next false unless r.sym.is_a?(Sym) && !r.sym.dotted? && r.name == name
85
+
86
+ r.target.nil? || (r.target.kind == :toplevel_fn && r.target.name == name)
87
+ end
88
+ result[uri] = occs unless occs.empty?
89
+ end
90
+ result
91
+ end
92
+
93
+ def toplevel_definition?(name, except_name: nil)
94
+ @entries.any? do |_uri, entry|
95
+ entry.walker.bindings.any? do |b|
96
+ next false unless file_toplevel_binding?(b)
97
+ next false if except_name && b.name == except_name
98
+
99
+ b.name == name
100
+ end
101
+ end
102
+ end
103
+
104
+ def constant_definition_with_prefix?(prefix, except_prefix: nil)
105
+ @entries.any? do |_uri, entry|
106
+ entry.walker.bindings.any? do |b|
107
+ next false unless %i[module class].include?(b.kind)
108
+ next false if except_prefix && matches_prefix?(b.sym, except_prefix)
109
+
110
+ matches_prefix?(b.sym, prefix)
111
+ end
112
+ end
113
+ end
114
+
115
+ def each_entry(&)
116
+ @entries.each(&)
117
+ end
118
+
119
+ def find_macro_definition(importing_uri, module_label, import_key)
120
+ target_name = import_key.to_s.tr('_', '-')
121
+ resolve_module_uris(importing_uri, module_label).each do |uri|
122
+ entry = @entries[uri]
123
+ next unless entry
124
+
125
+ binding = entry.walker.bindings.find do |b|
126
+ %i[macro toplevel_fn].include?(b.kind) && b.name == target_name
127
+ end
128
+ return [uri, binding] if binding
129
+ end
130
+ nil
131
+ end
132
+
133
+ def import_resolves_to?(importing_uri, module_label, target_uri)
134
+ resolve_module_uris(importing_uri, module_label).include?(target_uri)
135
+ end
136
+
137
+ def macro_definition_anywhere?(name, except_uri: nil)
138
+ @entries.any? do |uri, entry|
139
+ next false if except_uri && uri == except_uri
140
+
141
+ entry.walker.bindings.any? { |b| b.kind == :macro && b.name == name }
142
+ end
143
+ end
144
+
145
+ def constant_occurrences(prefix)
146
+ result = {}
147
+ @entries.each do |uri, entry|
148
+ occs = []
149
+ entry.walker.bindings.each do |b|
150
+ next unless %i[module class].include?(b.kind)
151
+
152
+ occs << b if matches_prefix?(b.sym, prefix)
153
+ end
154
+ entry.walker.references.each do |r|
155
+ sym = r.sym
156
+ next unless sym.is_a?(Sym)
157
+ next unless r.target.nil?
158
+ next unless first_segment_capitalized?(sym)
159
+
160
+ occs << r if matches_prefix?(sym, prefix)
161
+ end
162
+ result[uri] = occs unless occs.empty?
163
+ end
164
+ result
165
+ end
166
+
167
+ private
168
+
169
+ def file_toplevel_binding?(binding)
170
+ binding.scope.kind == :file && %i[toplevel_fn local var].include?(binding.kind)
171
+ end
172
+
173
+ def matches_prefix?(sym, prefix)
174
+ segs = sym.dotted? ? sym.segments : [sym.name]
175
+ return false if segs.length < prefix.length
176
+
177
+ segs[0...prefix.length] == prefix
178
+ end
179
+
180
+ def resolve_module_uris(importing_uri, module_label)
181
+ importing_path = uri_to_path(importing_uri)
182
+ return [] unless importing_path
183
+
184
+ base_dir = File.dirname(importing_path)
185
+ snake_stem = module_label.to_s.tr('-', '_')
186
+ kebab_stem = module_label.to_s.tr('_', '-')
187
+ uris = []
188
+ [kebab_stem, snake_stem].uniq.each do |stem|
189
+ MACRO_MODULE_EXTENSIONS.each do |ext|
190
+ uris << path_to_uri(File.expand_path("#{stem}.#{ext}", base_dir))
191
+ end
192
+ end
193
+ uris
194
+ end
195
+
196
+ def first_segment_capitalized?(sym)
197
+ first = sym.dotted? ? sym.segments.first : sym.name
198
+ first.match?(/\A[A-Z]/)
199
+ end
200
+
201
+ def store(uri, text)
202
+ forms = Reader.read_all(text)
203
+ walker = ScopeWalker.analyze(forms)
204
+ @entries[uri] = Entry.new(uri:, text:, forms:, walker:)
205
+ rescue Kapusta::Error
206
+ @entries[uri] = Entry.new(uri:, text:, forms: [], walker: ScopeWalker.analyze([]))
207
+ end
208
+
209
+ def path_to_uri(path)
210
+ "file://#{URI::DEFAULT_PARSER.escape(File.expand_path(path))}"
211
+ end
212
+
213
+ def uri_to_path(uri)
214
+ return unless uri.is_a?(String)
215
+
216
+ parsed = URI.parse(uri)
217
+ return URI::DEFAULT_PARSER.unescape(parsed.path) if parsed.scheme == 'file'
218
+
219
+ uri
220
+ rescue URI::InvalidURIError
221
+ nil
222
+ end
223
+ end
224
+ end
225
+ end
data/lib/kapusta/lsp.rb CHANGED
@@ -3,13 +3,16 @@
3
3
  require 'json'
4
4
  require 'uri'
5
5
  require_relative '../kapusta'
6
- require_relative 'formatter'
6
+ require_relative 'lsp/definition'
7
+ require_relative 'lsp/diagnostics'
8
+ require_relative 'lsp/formatting'
9
+ require_relative 'lsp/rename'
10
+ require_relative 'lsp/workspace_index'
7
11
 
8
12
  module Kapusta
9
13
  class LSP
10
14
  NOT_INITIALIZED = -32_002
11
15
  METHOD_NOT_FOUND = -32_601
12
- SEVERITY_ERROR = 1
13
16
  FULL_SYNC = 1
14
17
 
15
18
  def self.start(input: $stdin, output: $stdout, log: $stderr)
@@ -20,7 +23,9 @@ module Kapusta
20
23
  @input = input.binmode
21
24
  @output = output.binmode
22
25
  @log = log
26
+ @debug = %w[1 true yes on].include?(ENV['KAPUSTA_LS_DEBUG'].to_s.downcase)
23
27
  @sources = {}
28
+ @workspace_index = WorkspaceIndex.new
24
29
  @initialized = false
25
30
  @shutdown = false
26
31
  end
@@ -94,6 +99,7 @@ module Kapusta
94
99
  def dispatch(method, id, params)
95
100
  case method
96
101
  when 'initialize'
102
+ on_initialize(params)
97
103
  @initialized = true
98
104
  reply(id, initialize_result)
99
105
  when 'initialized' then nil
@@ -106,6 +112,9 @@ module Kapusta
106
112
  when 'textDocument/didSave' then on_did_save(params)
107
113
  when 'textDocument/didClose' then on_did_close(params)
108
114
  when 'textDocument/formatting' then reply(id, formatting(params))
115
+ when 'textDocument/definition' then reply(id, definition(params))
116
+ when 'textDocument/prepareRename' then reply(id, prepare_rename(params))
117
+ when 'textDocument/rename' then handle_rename(id, params)
109
118
  else
110
119
  reply_error(id, METHOD_NOT_FOUND, "method not found: #{method}")
111
120
  end
@@ -131,12 +140,28 @@ module Kapusta
131
140
  {
132
141
  capabilities: {
133
142
  textDocumentSync: { openClose: true, change: FULL_SYNC, save: { includeText: false } },
134
- documentFormattingProvider: true
143
+ documentFormattingProvider: true,
144
+ definitionProvider: true,
145
+ renameProvider: { prepareProvider: true }
135
146
  },
136
147
  serverInfo: { name: 'kapusta-ls', version: Kapusta::VERSION }
137
148
  }
138
149
  end
139
150
 
151
+ def on_initialize(params)
152
+ folders = params['workspaceFolders'] || []
153
+ roots = folders.filter_map { |f| uri_to_path(f['uri']) }
154
+ roots << uri_to_path(params['rootUri']) if params['rootUri']
155
+ roots.compact!
156
+ roots.uniq!
157
+ debug("initialize: roots=#{roots.inspect}")
158
+ @workspace_index = WorkspaceIndex.new(roots:)
159
+ @workspace_index.scan!
160
+ debug("workspace scan complete: #{@workspace_index.entry_count} files")
161
+ rescue StandardError => e
162
+ log("workspace scan failed: #{e.class}: #{e.message}")
163
+ end
164
+
140
165
  def on_did_open(params)
141
166
  doc = params['textDocument'] || {}
142
167
  uri = doc['uri']
@@ -144,7 +169,9 @@ module Kapusta
144
169
 
145
170
  version = doc['version']
146
171
  text = doc['text'] || ''
172
+ debug("didOpen: uri=#{uri} version=#{version} bytes=#{text.bytesize}")
147
173
  store(uri, text, version)
174
+ @workspace_index.refresh(uri, text)
148
175
  publish_diagnostics(uri, text, version)
149
176
  end
150
177
 
@@ -155,7 +182,9 @@ module Kapusta
155
182
  return if uri.nil? || changes.empty?
156
183
 
157
184
  text = changes.last['text']
185
+ debug("didChange: uri=#{uri} version=#{version} bytes=#{text.bytesize}")
158
186
  store(uri, text, version)
187
+ @workspace_index.refresh(uri, text)
159
188
  publish_diagnostics(uri, text, version)
160
189
  end
161
190
 
@@ -164,6 +193,8 @@ module Kapusta
164
193
  entry = @sources[uri]
165
194
  return unless entry
166
195
 
196
+ debug("didSave: uri=#{uri}")
197
+ @workspace_index.refresh(uri, entry[:text])
167
198
  publish_diagnostics(uri, entry[:text], entry[:version])
168
199
  end
169
200
 
@@ -171,7 +202,9 @@ module Kapusta
171
202
  uri = params.dig('textDocument', 'uri')
172
203
  return unless uri
173
204
 
205
+ debug("didClose: uri=#{uri}")
174
206
  @sources.delete(uri)
207
+ @workspace_index.remove(uri)
175
208
  notify('textDocument/publishDiagnostics', { uri:, diagnostics: [] })
176
209
  end
177
210
 
@@ -180,64 +213,79 @@ module Kapusta
180
213
  entry = @sources[uri]
181
214
  return [] unless entry
182
215
 
183
- formatted = Kapusta::Formatter.format(entry[:text], path: uri_to_path(uri))
184
- return [] if formatted == entry[:text]
185
-
186
- [{ range: full_range(entry[:text]), newText: formatted }]
187
- rescue Kapusta::Error
188
- []
216
+ Formatting.text_edits(entry[:text], uri_to_path(uri))
189
217
  end
190
218
 
191
- def store(uri, text, version)
192
- @sources[uri] = { text:, version: }
193
- end
219
+ def definition(params)
220
+ uri = params.dig('textDocument', 'uri')
221
+ pos = params['position'] || {}
222
+ entry = @sources[uri]
223
+ return unless entry
194
224
 
195
- def publish_diagnostics(uri, text, version)
196
- diagnostics = collect_diagnostics(text, uri_to_path(uri))
197
- params = { uri:, diagnostics: }
198
- params[:version] = version unless version.nil?
199
- notify('textDocument/publishDiagnostics', params)
225
+ result = Definition.find(uri, entry[:text], pos['line'] || 0, pos['character'] || 0,
226
+ workspace_index: @workspace_index)
227
+ debug("definition: uri=#{uri} pos=#{pos.inspect} result=#{result.inspect}")
228
+ result
200
229
  end
201
230
 
202
- def collect_diagnostics(text, path)
203
- Kapusta.compile(text, path: path || '(buffer)')
204
- []
205
- rescue Kapusta::Error => e
206
- [diagnostic_from(e, text)]
231
+ def prepare_rename(params)
232
+ uri = params.dig('textDocument', 'uri')
233
+ pos = params['position'] || {}
234
+ entry = @sources[uri]
235
+ unless entry
236
+ debug("prepareRename: no source for uri=#{uri.inspect}; tracked=#{@sources.keys.inspect}")
237
+ return
238
+ end
239
+
240
+ result = Rename.prepare(entry[:text], pos['line'] || 0, pos['character'] || 0)
241
+ debug("prepareRename: uri=#{uri} pos=#{pos.inspect} result=#{result.inspect}")
242
+ result
207
243
  end
208
244
 
209
- def diagnostic_from(error, text)
210
- line = [(error.line || 1) - 1, 0].max
211
- column = [(error.column || 1) - 1, 0].max
245
+ def handle_rename(id, params)
246
+ uri = params.dig('textDocument', 'uri')
247
+ pos = params['position'] || {}
248
+ new_name = params['newName']
249
+ entry = @sources[uri]
250
+ unless entry
251
+ debug("rename: no source for uri=#{uri.inspect}; tracked=#{@sources.keys.inspect}")
252
+ return reply(id, nil)
253
+ end
212
254
 
213
- {
214
- range: {
215
- start: { line:, character: column },
216
- end: { line:, character: column + token_length(text, line, column) }
217
- },
218
- severity: SEVERITY_ERROR,
219
- source: 'kapusta-ls',
220
- message: error.reason
221
- }
255
+ debug("rename: uri=#{uri} pos=#{pos.inspect} newName=#{new_name.inspect}")
256
+ result = Rename.perform(uri, entry[:text], pos['line'] || 0, pos['character'] || 0,
257
+ new_name, workspace_index: @workspace_index)
258
+ if result[:error]
259
+ debug("rename error: #{result[:error].inspect}")
260
+ reply_error(id, result[:error][:code], result[:error][:message])
261
+ else
262
+ edit = build_workspace_edit(result[:changes])
263
+ debug("rename ok: files=#{result[:changes].keys.length} edits=#{result[:changes].values.sum(&:length)}")
264
+ reply(id, edit)
265
+ end
222
266
  end
223
267
 
224
- def token_length(text, line, column)
225
- source_line = text.lines[line]
226
- return 1 unless source_line
268
+ def build_workspace_edit(changes_by_uri)
269
+ document_changes = changes_by_uri.map do |uri, edits|
270
+ sorted = edits.sort_by { |e| [-e[:range][:start][:line], -e[:range][:start][:character]] }
271
+ version = @sources.dig(uri, :version)
272
+ {
273
+ textDocument: { uri:, version: },
274
+ edits: sorted
275
+ }
276
+ end
277
+ { documentChanges: document_changes }
278
+ end
227
279
 
228
- tail = source_line[column..] || ''
229
- match = tail.match(/\A[^\s()\[\]{}";`,]+/)
230
- match && match[0].length.positive? ? match[0].length : 1
280
+ def store(uri, text, version)
281
+ @sources[uri] = { text:, version: }
231
282
  end
232
283
 
233
- def full_range(text)
234
- lines = text.split("\n", -1)
235
- end_line = [lines.length - 1, 0].max
236
- end_character = lines.last ? lines.last.length : 0
237
- {
238
- start: { line: 0, character: 0 },
239
- end: { line: end_line, character: end_character }
240
- }
284
+ def publish_diagnostics(uri, text, version)
285
+ diagnostics = Diagnostics.collect(text, uri_to_path(uri))
286
+ params = { uri:, diagnostics: }
287
+ params[:version] = version unless version.nil?
288
+ notify('textDocument/publishDiagnostics', params)
241
289
  end
242
290
 
243
291
  def uri_to_path(uri)
@@ -254,5 +302,11 @@ module Kapusta
254
302
  def log(message)
255
303
  @log.puts "kapusta-ls: #{message}"
256
304
  end
305
+
306
+ def debug(message)
307
+ return unless @debug
308
+
309
+ @log.puts "kapusta-ls[debug]: #{message}"
310
+ end
257
311
  end
258
312
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kapusta
4
- VERSION = '0.7.0'
4
+ VERSION = '0.8.0'
5
5
  end
@@ -154,7 +154,23 @@ RSpec.describe 'examples-errors' do
154
154
 
155
155
  it 'import-macros-missing-module.kap' do
156
156
  expect(run_error_example('import-macros-missing-module.kap'))
157
- .to eq("import-macros-missing-module.kap:4:1: import-macros is not yet supported\n")
157
+ .to eq("import-macros-missing-module.kap:4:1: import-macros: module nonexistent-module not found\n")
158
+ end
159
+
160
+ it 'import-macros-macro-not-found.kap' do
161
+ message = 'import-macros: macro missing not exported by module missing-macro-helper'
162
+ expect(run_error_example('import-macros-macro-not-found.kap'))
163
+ .to eq("import-macros-macro-not-found.kap:1:1: #{message}\n")
164
+ end
165
+
166
+ it 'import-macros-no-exports.kap' do
167
+ expect(run_error_example('import-macros-no-exports.kap'))
168
+ .to eq("import-macros-no-exports.kap:1:1: import-macros: module no-exports-helper has no export table\n")
169
+ end
170
+
171
+ it 'import-macros-module-invalid.kap' do
172
+ expect(run_error_example('import-macros-module-invalid.kap'))
173
+ .to eq("import-macros-module-invalid.kap:1:1: import-macros expects a symbol or string module name\n")
158
174
  end
159
175
 
160
176
  it 'invalid-class-name.kap' do
@@ -551,4 +551,16 @@ RSpec.describe 'examples' do
551
551
  50
552
552
  OUT
553
553
  end
554
+
555
+ it 'macros-import.kap' do
556
+ expect(run_example('macros-import.kap')).to eq("8\n")
557
+ end
558
+
559
+ it 'macros-import-helpers.kap' do
560
+ expect(run_example('macros-import-helpers.kap')).to eq("60\n")
561
+ end
562
+
563
+ it 'macros-import-whole.kap' do
564
+ expect(run_example('macros-import-whole.kap')).to eq("7\n")
565
+ end
554
566
  end