kapusta 0.7.0 → 0.9.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.
data/spec/lsp_spec.rb CHANGED
@@ -4,6 +4,7 @@ require 'spec_helper'
4
4
  require 'kapusta/lsp'
5
5
  require 'json'
6
6
  require 'stringio'
7
+ require 'tmpdir'
7
8
 
8
9
  RSpec.describe Kapusta::LSP do
9
10
  def frame(payload)
@@ -30,29 +31,81 @@ RSpec.describe Kapusta::LSP do
30
31
  parse_responses(output.string)
31
32
  end
32
33
 
34
+ def frame_initialize(folders = [])
35
+ workspace_folders = folders.map { |uri| { uri:, name: 'tmp' } }
36
+ frame(jsonrpc: '2.0', id: 1, method: 'initialize',
37
+ params: { workspaceFolders: workspace_folders })
38
+ end
39
+
40
+ def frame_did_open(uri, text, version: 1)
41
+ frame(jsonrpc: '2.0', method: 'textDocument/didOpen',
42
+ params: { textDocument: { uri:, version:, text: } })
43
+ end
44
+
45
+ def frame_rename(uri:, line:, character:, new_name:, id: 2)
46
+ frame(jsonrpc: '2.0', id:, method: 'textDocument/rename',
47
+ params: { textDocument: { uri: }, position: { line:, character: }, newName: new_name })
48
+ end
49
+
50
+ def frame_prepare_rename(uri:, line:, character:, id: 2)
51
+ frame(jsonrpc: '2.0', id:, method: 'textDocument/prepareRename',
52
+ params: { textDocument: { uri: }, position: { line:, character: } })
53
+ end
54
+
55
+ def frame_definition(uri:, line:, character:, id: 2)
56
+ frame(jsonrpc: '2.0', id:, method: 'textDocument/definition',
57
+ params: { textDocument: { uri: }, position: { line:, character: } })
58
+ end
59
+
60
+ def frame_formatting(uri:, id: 2)
61
+ frame(jsonrpc: '2.0', id:, method: 'textDocument/formatting',
62
+ params: { textDocument: { uri: } })
63
+ end
64
+
65
+ def cursor_at(text, marker)
66
+ idx = text.index(marker) or raise "marker #{marker.inspect} not found in text"
67
+ prefix = text[0...idx]
68
+ last_nl = prefix.rindex("\n")
69
+ { line: prefix.count("\n"), character: last_nl ? idx - last_nl - 1 : idx }
70
+ end
71
+
72
+ def result_for(responses, id = 2)
73
+ responses.find { |m| m['id'] == id }
74
+ end
75
+
76
+ def with_workspace(files)
77
+ Dir.mktmpdir do |dir|
78
+ uris = files.to_h do |name, text|
79
+ path = File.join(dir, name)
80
+ File.write(path, text)
81
+ [name, "file://#{File.expand_path(path)}"]
82
+ end
83
+ root_uri = "file://#{File.expand_path(dir)}"
84
+ yield(root_uri, uris)
85
+ end
86
+ end
87
+
33
88
  it 'advertises diagnostics and formatting capabilities on initialize' do
34
- responses = run(frame(jsonrpc: '2.0', id: 1, method: 'initialize', params: {}))
89
+ responses = run(frame_initialize)
35
90
  capabilities = responses.first.dig('result', 'capabilities')
36
91
 
37
92
  expect(capabilities).to include('textDocumentSync', 'documentFormattingProvider')
93
+ expect(capabilities['definitionProvider']).to be(true)
38
94
  end
39
95
 
40
96
  it 'publishes diagnostics for invalid source' do
41
97
  responses = run(
42
- frame(jsonrpc: '2.0', id: 1, method: 'initialize', params: {}),
43
- frame(jsonrpc: '2.0', method: 'textDocument/didOpen',
44
- params: { textDocument: { uri: 'file:///x.kap', version: 1, text: '(let [x 1] (+ x ()))' } })
98
+ frame_initialize,
99
+ frame_did_open('file:///x.kap', '(let [x 1] (+ x ()))')
45
100
  )
46
- diagnostics = responses.last.dig('params', 'diagnostics')
47
101
 
48
- expect(diagnostics).not_to be_empty
102
+ expect(responses.last.dig('params', 'diagnostics')).not_to be_empty
49
103
  end
50
104
 
51
105
  it 'publishes no diagnostics for valid source' do
52
106
  responses = run(
53
- frame(jsonrpc: '2.0', id: 1, method: 'initialize', params: {}),
54
- frame(jsonrpc: '2.0', method: 'textDocument/didOpen',
55
- params: { textDocument: { uri: 'file:///x.kap', version: 1, text: '(print "hi")' } })
107
+ frame_initialize,
108
+ frame_did_open('file:///x.kap', '(print "hi")')
56
109
  )
57
110
 
58
111
  expect(responses.last.dig('params', 'diagnostics')).to be_empty
@@ -60,13 +113,11 @@ RSpec.describe Kapusta::LSP do
60
113
 
61
114
  it 'returns a TextEdit for formatting' do
62
115
  responses = run(
63
- frame(jsonrpc: '2.0', id: 1, method: 'initialize', params: {}),
64
- frame(jsonrpc: '2.0', method: 'textDocument/didOpen',
65
- params: { textDocument: { uri: 'file:///x.kap', version: 1, text: "(fn greet [x] (print x))\n" } }),
66
- frame(jsonrpc: '2.0', id: 2, method: 'textDocument/formatting',
67
- params: { textDocument: { uri: 'file:///x.kap' } })
116
+ frame_initialize,
117
+ frame_did_open('file:///x.kap', "(fn greet [x] (print x))\n"),
118
+ frame_formatting(uri: 'file:///x.kap')
68
119
  )
69
- edits = responses.find { |m| m['id'] == 2 }['result']
120
+ edits = result_for(responses)['result']
70
121
 
71
122
  expect(edits).to be_an(Array).and(be_one)
72
123
  expect(edits.first).to include('range', 'newText')
@@ -80,4 +131,559 @@ RSpec.describe Kapusta::LSP do
80
131
 
81
132
  expect(responses.first.dig('error', 'code')).to eq(-32_002)
82
133
  end
134
+
135
+ it 'renames a let binding within a single file' do
136
+ text = '(let [x 1] (+ x x))'
137
+ responses = run(
138
+ frame_initialize,
139
+ frame_did_open('file:///x.kap', text),
140
+ frame_rename(uri: 'file:///x.kap', **cursor_at(text, 'x'), new_name: 'y')
141
+ )
142
+ changes = result_for(responses)['result']['documentChanges']
143
+
144
+ expect(changes.length).to eq(1)
145
+ expect(changes.first['edits'].map { |e| e['newText'] }).to eq(%w[y y y])
146
+ end
147
+
148
+ it 'renames a let binding referenced inside an accumulate iterator with multiple binders' do
149
+ text = "(let [xs [1 2 3]\n total (accumulate [s 0 _ x (ipairs xs)] (+ s x))]\n (print total))\n"
150
+ idx = text.index('(ipairs xs)') + 'ipairs '.length + 1
151
+ prefix = text[0...idx]
152
+ last_nl = prefix.rindex("\n")
153
+ position = { line: prefix.count("\n"), character: last_nl ? idx - last_nl - 1 : idx }
154
+ responses = run(
155
+ frame_initialize,
156
+ frame_did_open('file:///x.kap', text),
157
+ frame_rename(uri: 'file:///x.kap', **position, new_name: 'ys')
158
+ )
159
+ changes = result_for(responses)['result']['documentChanges']
160
+
161
+ expect(changes.length).to eq(1)
162
+ expect(changes.first['edits'].map { |e| e['newText'] }).to eq(%w[ys ys])
163
+ end
164
+
165
+ it 'renames a for-loop counter referenced inside &until' do
166
+ text = "(for [d 2 10 &until (>= d 5)] (print d))\n"
167
+ responses = run(
168
+ frame_initialize,
169
+ frame_did_open('file:///x.kap', text),
170
+ frame_rename(uri: 'file:///x.kap', **cursor_at(text, 'd'), new_name: 'k')
171
+ )
172
+ edits = result_for(responses)['result']['documentChanges'].first['edits']
173
+
174
+ expect(edits.map { |e| e['newText'] }).to eq(%w[k k k])
175
+ end
176
+
177
+ it 'renames a top-level fn across files' do
178
+ text_a = "(fn greet [n] (print n))\n(greet \"x\")\n"
179
+ text_b = "(greet 42)\n"
180
+ with_workspace('a.kap' => text_a, 'b.kap' => text_b) do |root_uri, uri|
181
+ responses = run(
182
+ frame_initialize([root_uri]),
183
+ frame_did_open(uri['a.kap'], text_a),
184
+ frame_rename(uri: uri['a.kap'], **cursor_at(text_a, 'greet'), new_name: 'hello')
185
+ )
186
+ changes = result_for(responses)['result']['documentChanges']
187
+
188
+ expect(changes.map { |c| c['textDocument']['uri'] }).to include(uri['a.kap'], uri['b.kap'])
189
+ end
190
+ end
191
+
192
+ it 'renames a module constant rewriting only the matching segment in dotted references' do
193
+ text_a = "(module Foo (fn bar [] 1))\n"
194
+ text_b = "(Foo.bar)\n(Foo.new)\n"
195
+ with_workspace('a.kap' => text_a, 'b.kap' => text_b) do |root_uri, uri|
196
+ responses = run(
197
+ frame_initialize([root_uri]),
198
+ frame_did_open(uri['a.kap'], text_a),
199
+ frame_rename(uri: uri['a.kap'], **cursor_at(text_a, 'Foo'), new_name: 'Bar')
200
+ )
201
+ b_edits = result_for(responses)['result']['documentChanges']
202
+ .find { |c| c['textDocument']['uri'] == uri['b.kap'] }['edits']
203
+
204
+ expect(b_edits.map { |e| [e['range']['start']['character'], e['range']['end']['character'], e['newText']] })
205
+ .to contain_exactly([1, 4, 'Bar'], [1, 4, 'Bar'])
206
+ end
207
+ end
208
+
209
+ it 'returns null from prepareRename on a dotted method segment' do
210
+ text = '(Foo.bar 1)'
211
+ responses = run(
212
+ frame_initialize,
213
+ frame_did_open('file:///x.kap', text),
214
+ frame_prepare_rename(uri: 'file:///x.kap', **cursor_at(text, 'bar'))
215
+ )
216
+
217
+ expect(result_for(responses)['result']).to be_nil
218
+ end
219
+
220
+ it 'renames a constant when the workspace contains hash patterns and the .. special form' do
221
+ text_a = "(class Foo)\n"
222
+ text_b = "(let [{:x x} {:x 1}] (print (.. \"value=\" x)))\n(Foo.new)\n"
223
+ with_workspace('a.kap' => text_a, 'b.kap' => text_b) do |root_uri, uri|
224
+ responses = run(
225
+ frame_initialize([root_uri]),
226
+ frame_did_open(uri['a.kap'], text_a),
227
+ frame_rename(uri: uri['a.kap'], **cursor_at(text_a, 'Foo'), new_name: 'Bar')
228
+ )
229
+ response = result_for(responses)
230
+
231
+ expect(response['error']).to be_nil
232
+ uris = response['result']['documentChanges'].map { |c| c['textDocument']['uri'] }
233
+ expect(uris).to include(uri['a.kap'], uri['b.kap'])
234
+ end
235
+ end
236
+
237
+ it 'does not offer rename for method definitions after a bodyless class header' do
238
+ text = "(class Counter)\n(fn initialize [start] start)\n"
239
+ responses = run(
240
+ frame_initialize,
241
+ frame_did_open('file:///x.kap', text),
242
+ frame_prepare_rename(uri: 'file:///x.kap', **cursor_at(text, 'initialize'))
243
+ )
244
+
245
+ expect(result_for(responses)['result']).to be_nil
246
+ end
247
+
248
+ it 'does not let class methods shadow top-level function references' do
249
+ text = "(fn helper [] 2)\n(class Foo (fn helper [] 1))\n(fn main [] (helper))\n"
250
+ responses = run(
251
+ frame_initialize,
252
+ frame_did_open('file:///x.kap', text),
253
+ frame_rename(uri: 'file:///x.kap', **cursor_at(text, 'helper'), new_name: 'renamed')
254
+ )
255
+ edits = result_for(responses)['result']['documentChanges'].first['edits']
256
+
257
+ expect(edits.map { |e| e['range']['start']['line'] }).to contain_exactly(0, 2)
258
+ end
259
+
260
+ it 'rejects top-level rename when the new name exists elsewhere in the workspace' do
261
+ text_a = "(fn foo [] 1)\n"
262
+ text_b = "(foo)\n"
263
+ text_c = "(fn bar [] 2)\n"
264
+ with_workspace('a.kap' => text_a, 'b.kap' => text_b, 'c.kap' => text_c) do |root_uri, uri|
265
+ responses = run(
266
+ frame_initialize([root_uri]),
267
+ frame_did_open(uri['a.kap'], text_a),
268
+ frame_rename(uri: uri['a.kap'], **cursor_at(text_a, 'foo'), new_name: 'bar')
269
+ )
270
+
271
+ expect(result_for(responses).dig('error', 'message')).to include('already defined')
272
+ end
273
+ end
274
+
275
+ it 'rejects constant rename when the new constant prefix exists' do
276
+ text_a = "(class Foo)\n"
277
+ text_b = "(class Bar)\n"
278
+ with_workspace('a.kap' => text_a, 'b.kap' => text_b) do |root_uri, uri|
279
+ responses = run(
280
+ frame_initialize([root_uri]),
281
+ frame_did_open(uri['a.kap'], text_a),
282
+ frame_rename(uri: uri['a.kap'], **cursor_at(text_a, 'Foo'), new_name: 'Bar')
283
+ )
284
+
285
+ expect(result_for(responses).dig('error', 'message')).to include('already defined')
286
+ end
287
+ end
288
+
289
+ it 'jumps to a let binder from a usage in the same file' do
290
+ text = '(let [x 1] (+ x x))'
291
+ responses = run(
292
+ frame_initialize,
293
+ frame_did_open('file:///x.kap', text),
294
+ frame_definition(uri: 'file:///x.kap', line: 0, character: 14)
295
+ )
296
+ result = result_for(responses)['result']
297
+
298
+ expect(result).to eq(
299
+ 'uri' => 'file:///x.kap',
300
+ 'range' => {
301
+ 'start' => { 'line' => 0, 'character' => 6 },
302
+ 'end' => { 'line' => 0, 'character' => 7 }
303
+ }
304
+ )
305
+ end
306
+
307
+ it 'jumps to a top-level fn definition across files' do
308
+ text_a = "(fn greet [n] (print n))\n"
309
+ text_b = "(greet 42)\n"
310
+ with_workspace('a.kap' => text_a, 'b.kap' => text_b) do |root_uri, uri|
311
+ responses = run(
312
+ frame_initialize([root_uri]),
313
+ frame_did_open(uri['b.kap'], text_b),
314
+ frame_definition(uri: uri['b.kap'], **cursor_at(text_b, 'greet'))
315
+ )
316
+ result = result_for(responses)['result']
317
+
318
+ expect(result).to be_an(Array).and(be_one)
319
+ expect(result.first['uri']).to eq(uri['a.kap'])
320
+ expect(result.first['range']['start']).to eq('line' => 0, 'character' => 4)
321
+ end
322
+ end
323
+
324
+ it 'jumps to a module definition from a dotted reference across files' do
325
+ text_a = "(module Foo (fn bar [] 1))\n"
326
+ text_b = "(Foo.bar)\n"
327
+ with_workspace('a.kap' => text_a, 'b.kap' => text_b) do |root_uri, uri|
328
+ responses = run(
329
+ frame_initialize([root_uri]),
330
+ frame_did_open(uri['b.kap'], text_b),
331
+ frame_definition(uri: uri['b.kap'], **cursor_at(text_b, 'Foo'))
332
+ )
333
+ result = result_for(responses)['result']
334
+
335
+ expect(result).to be_an(Array).and(be_one)
336
+ expect(result.first['uri']).to eq(uri['a.kap'])
337
+ expect(result.first['range']['start']).to eq('line' => 0, 'character' => 8)
338
+ end
339
+ end
340
+
341
+ it 'jumps to a macro definition from a call site in the same file' do
342
+ text = "(macro unless [c & body] `(if (not ,c) (do ,(unpack body))))\n(unless false (print 1))\n"
343
+ with_workspace('a.kap' => text) do |root_uri, uri|
344
+ responses = run(
345
+ frame_initialize([root_uri]),
346
+ frame_did_open(uri['a.kap'], text),
347
+ frame_definition(uri: uri['a.kap'], line: 1, character: 1)
348
+ )
349
+ result = result_for(responses)['result']
350
+
351
+ expect(result).to eq(
352
+ 'uri' => uri['a.kap'],
353
+ 'range' => {
354
+ 'start' => { 'line' => 0, 'character' => 7 },
355
+ 'end' => { 'line' => 0, 'character' => 13 }
356
+ }
357
+ )
358
+ end
359
+ end
360
+
361
+ it 'jumps from a call site through (import-macros) to the macro definition file' do
362
+ text_a = "(macro swap! [a b] `(let [tmp# ,a] (set ,a ,b) (set ,b tmp#)))\n"
363
+ text_b = "(import-macros {: swap!} :a)\n(var x 1)\n(var y 2)\n(swap! x y)\n"
364
+ with_workspace('a.kap' => text_a, 'b.kap' => text_b) do |root_uri, uri|
365
+ responses = run(
366
+ frame_initialize([root_uri]),
367
+ frame_did_open(uri['b.kap'], text_b),
368
+ frame_definition(uri: uri['b.kap'], line: 3, character: 1)
369
+ )
370
+ result = result_for(responses)['result']
371
+
372
+ expect(result['uri']).to eq(uri['a.kap'])
373
+ expect(result['range']['start']).to eq('line' => 0, 'character' => 7)
374
+ end
375
+ end
376
+
377
+ it 'returns null definition when the symbol has no known binding' do
378
+ text = "(foo)\n"
379
+ responses = run(
380
+ frame_initialize,
381
+ frame_did_open('file:///x.kap', text),
382
+ frame_definition(uri: 'file:///x.kap', **cursor_at(text, 'foo'))
383
+ )
384
+
385
+ expect(result_for(responses)['result']).to be_nil
386
+ end
387
+
388
+ it 'renames a macro definition and its call sites within a file' do
389
+ text = "(macro swap! [a b]\n `(let [tmp# ,a]\n (set ,a ,b)\n (set ,b tmp#)))\n(swap! x y)\n"
390
+ with_workspace('a.kap' => text) do |root_uri, uri|
391
+ responses = run(
392
+ frame_initialize([root_uri]),
393
+ frame_did_open(uri['a.kap'], text),
394
+ frame_rename(uri: uri['a.kap'], **cursor_at(text, 'swap!'), new_name: 'flip!')
395
+ )
396
+ changes = result_for(responses)['result']['documentChanges']
397
+
398
+ expect(changes.length).to eq(1)
399
+ edits = changes.first['edits']
400
+ expect(edits.map { |e| e['newText'] }).to eq(%w[flip! flip!])
401
+ end
402
+ end
403
+
404
+ it 'renames a macro across files when imported via shorthand destructure' do
405
+ text_a = "(macro swap! [a b]\n `(let [tmp# ,a] (set ,a ,b) (set ,b tmp#)))\n"
406
+ text_b = "(import-macros {: swap!} :a)\n(var x 1)\n(var y 2)\n(swap! x y)\n"
407
+ with_workspace('a.kap' => text_a, 'b.kap' => text_b) do |root_uri, uri|
408
+ responses = run(
409
+ frame_initialize([root_uri]),
410
+ frame_did_open(uri['a.kap'], text_a),
411
+ frame_rename(uri: uri['a.kap'], **cursor_at(text_a, 'swap!'), new_name: 'flip!')
412
+ )
413
+ changes = result_for(responses)['result']['documentChanges']
414
+
415
+ uris = changes.map { |c| c['textDocument']['uri'] }
416
+ expect(uris).to include(uri['a.kap'], uri['b.kap'])
417
+
418
+ b_edits = changes.find { |c| c['textDocument']['uri'] == uri['b.kap'] }['edits']
419
+ expect(b_edits.map { |e| e['newText'] }).to all(eq('flip!'))
420
+ expect(b_edits.length).to eq(2)
421
+ end
422
+ end
423
+
424
+ it 'renames a macro across files when starting from an imported call site' do
425
+ text_a = "(macro swap! [a b]\n `(let [tmp# ,a] (set ,a ,b) (set ,b tmp#)))\n"
426
+ text_b = "(import-macros {: swap!} :a)\n(var x 1)\n(var y 2)\n(swap! x y)\n"
427
+ with_workspace('a.kap' => text_a, 'b.kap' => text_b) do |root_uri, uri|
428
+ responses = run(
429
+ frame_initialize([root_uri]),
430
+ frame_did_open(uri['b.kap'], text_b),
431
+ frame_rename(uri: uri['b.kap'],
432
+ line: 3, character: cursor_at(text_b.lines[3], 'swap!')[:character],
433
+ new_name: 'flip!')
434
+ )
435
+ changes = result_for(responses)['result']['documentChanges']
436
+
437
+ uris = changes.map { |c| c['textDocument']['uri'] }
438
+ expect(uris).to include(uri['a.kap'], uri['b.kap'])
439
+ end
440
+ end
441
+
442
+ it 'renames a user macro that shadows a core special form name' do
443
+ text = "(macro unless [c & body] `(if (not ,c) (do ,(unpack body))))\n" \
444
+ "(unless false (print \"x\"))\n(unless true (print \"y\"))\n"
445
+ with_workspace('a.kap' => text) do |root_uri, uri|
446
+ responses = run(
447
+ frame_initialize([root_uri]),
448
+ frame_did_open(uri['a.kap'], text),
449
+ frame_rename(uri: uri['a.kap'], **cursor_at(text, 'unless'), new_name: 'except')
450
+ )
451
+ edits = result_for(responses)['result']['documentChanges'].first['edits']
452
+
453
+ expect(edits.map { |e| e['newText'] }).to eq(%w[except except except])
454
+ end
455
+ end
456
+
457
+ it 'offers prepareRename on a macro call site shadowing a special form' do
458
+ text = "(macro unless [c & body] `(if (not ,c) (do ,(unpack body))))\n(unless false 1)\n"
459
+ with_workspace('a.kap' => text) do |root_uri, uri|
460
+ responses = run(
461
+ frame_initialize([root_uri]),
462
+ frame_did_open(uri['a.kap'], text),
463
+ frame_prepare_rename(uri: uri['a.kap'], line: 1, character: 1)
464
+ )
465
+ result = result_for(responses)['result']
466
+
467
+ expect(result).to include('range', 'placeholder')
468
+ expect(result['placeholder']).to eq('unless')
469
+ expect(result['range']['start']).to eq('line' => 1, 'character' => 1)
470
+ expect(result['range']['end']).to eq('line' => 1, 'character' => 7)
471
+ end
472
+ end
473
+
474
+ it 'returns null prepareRename for an unshadowed special form call' do
475
+ text = "(unless false 1)\n"
476
+ responses = run(
477
+ frame_initialize,
478
+ frame_did_open('file:///x.kap', text),
479
+ frame_prepare_rename(uri: 'file:///x.kap', line: 0, character: 1)
480
+ )
481
+
482
+ expect(result_for(responses)['result']).to be_nil
483
+ end
484
+
485
+ it 'renames only the targeted import when multiple macros are destructured together' do
486
+ text_a = "(macro foo [x] `,x)\n(macro bar [x] `,x)\n"
487
+ text_b = "(import-macros {: foo : bar} :a)\n(foo 1)\n(bar 2)\n"
488
+ with_workspace('a.kap' => text_a, 'b.kap' => text_b) do |root_uri, uri|
489
+ responses = run(
490
+ frame_initialize([root_uri]),
491
+ frame_did_open(uri['a.kap'], text_a),
492
+ frame_rename(uri: uri['a.kap'], **cursor_at(text_a, 'foo'), new_name: 'qux')
493
+ )
494
+ changes = result_for(responses)['result']['documentChanges']
495
+ b_edits = changes.find { |c| c['textDocument']['uri'] == uri['b.kap'] }['edits']
496
+
497
+ expect(b_edits.map { |e| e['newText'] }).to all(eq('qux'))
498
+ expect(b_edits.length).to eq(2)
499
+ expect(b_edits.flat_map { |e| [e['range']['start']['line'], e['range']['end']['line']] })
500
+ .to all(satisfy { |n| [0, 1].include?(n) })
501
+ end
502
+ end
503
+
504
+ it 'propagates a macro rename to multiple importing files' do
505
+ text_a = "(macro tap [x] `(do (print ,x) ,x))\n"
506
+ text_b = "(import-macros {: tap} :a)\n(tap 1)\n"
507
+ text_c = "(import-macros {: tap} :a)\n(tap 2)\n(tap 3)\n"
508
+ with_workspace('a.kap' => text_a, 'b.kap' => text_b, 'c.kap' => text_c) do |root_uri, uri|
509
+ responses = run(
510
+ frame_initialize([root_uri]),
511
+ frame_did_open(uri['a.kap'], text_a),
512
+ frame_rename(uri: uri['a.kap'], **cursor_at(text_a, 'tap'), new_name: 'spy')
513
+ )
514
+ changes = result_for(responses)['result']['documentChanges']
515
+
516
+ expect(changes.map { |c| c['textDocument']['uri'] })
517
+ .to contain_exactly(uri['a.kap'], uri['b.kap'], uri['c.kap'])
518
+
519
+ changes.each do |c|
520
+ expect(c['edits'].map { |e| e['newText'] }).to all(eq('spy'))
521
+ end
522
+
523
+ c_edits = changes.find { |c| c['textDocument']['uri'] == uri['c.kap'] }['edits']
524
+ expect(c_edits.length).to eq(3)
525
+ end
526
+ end
527
+
528
+ it 'jumps to itself when invoked on a macro definition name' do
529
+ text = "(macro tap [x] `(do (print ,x) ,x))\n"
530
+ with_workspace('a.kap' => text) do |root_uri, uri|
531
+ responses = run(
532
+ frame_initialize([root_uri]),
533
+ frame_did_open(uri['a.kap'], text),
534
+ frame_definition(uri: uri['a.kap'], **cursor_at(text, 'tap'))
535
+ )
536
+ result = result_for(responses)['result']
537
+
538
+ expect(result).to eq(
539
+ 'uri' => uri['a.kap'],
540
+ 'range' => {
541
+ 'start' => { 'line' => 0, 'character' => 7 },
542
+ 'end' => { 'line' => 0, 'character' => 10 }
543
+ }
544
+ )
545
+ end
546
+ end
547
+
548
+ it 'jumps through (import-macros) to a fn-defined macro template in a .kapm module' do
549
+ text_helper = "(fn scaled [x] `(* ,x 10))\n{: scaled}\n"
550
+ text_user = "(import-macros {: scaled} :import-helpers)\n(print (scaled 3))\n"
551
+ with_workspace('import-helpers.kapm' => text_helper,
552
+ 'macros-import-helpers.kap' => text_user) do |root_uri, uri|
553
+ responses = run(
554
+ frame_initialize([root_uri]),
555
+ frame_did_open(uri['macros-import-helpers.kap'], text_user),
556
+ frame_definition(uri: uri['macros-import-helpers.kap'],
557
+ **cursor_at(text_user.lines[1], 'scaled').merge(line: 1))
558
+ )
559
+ result = result_for(responses)['result']
560
+
561
+ expect(result['uri']).to eq(uri['import-helpers.kapm'])
562
+ expect(result['range']['start']).to eq('line' => 0, 'character' => 4)
563
+ end
564
+ end
565
+
566
+ it 'returns null definition for a special-form call with no shadowing macro' do
567
+ text = "(unless false 1)\n"
568
+ responses = run(
569
+ frame_initialize,
570
+ frame_did_open('file:///x.kap', text),
571
+ frame_definition(uri: 'file:///x.kap', line: 0, character: 1)
572
+ )
573
+
574
+ expect(result_for(responses)['result']).to be_nil
575
+ end
576
+
577
+ it 'rejects macro rename when the new name conflicts with an existing macro definition' do
578
+ text_a = "(macro swap! [a b] `(do ,a ,b))\n(macro flip! [a b] `(do ,b ,a))\n"
579
+ with_workspace('a.kap' => text_a) do |root_uri, uri|
580
+ responses = run(
581
+ frame_initialize([root_uri]),
582
+ frame_did_open(uri['a.kap'], text_a),
583
+ frame_rename(uri: uri['a.kap'], **cursor_at(text_a, 'swap!'), new_name: 'flip!')
584
+ )
585
+
586
+ expect(result_for(responses).dig('error', 'message')).to include('already defined')
587
+ end
588
+ end
589
+
590
+ it 'renames an @ivar across methods within a class' do
591
+ text = "(class C)\n(fn one [] (set @counter 1))\n(fn two [] @counter)\n"
592
+ responses = run(
593
+ frame_initialize,
594
+ frame_did_open('file:///x.kap', text),
595
+ frame_rename(uri: 'file:///x.kap', **cursor_at(text, 'counter'), new_name: 'total')
596
+ )
597
+ changes = result_for(responses)['result']['documentChanges']
598
+
599
+ expect(changes.length).to eq(1)
600
+ edits = changes.first['edits']
601
+ expect(edits.map { |e| e['newText'] }).to eq(%w[total total])
602
+ expect(edits.map { |e| [e['range']['start']['line'], e['range']['start']['character']] })
603
+ .to contain_exactly([1, 17], [2, 12])
604
+ end
605
+
606
+ it 'renames @ivar without touching a same-named fn parameter or value reference' do
607
+ text = "(class C)\n(fn init [val] (set @counter val))\n"
608
+ responses = run(
609
+ frame_initialize,
610
+ frame_did_open('file:///x.kap', text),
611
+ frame_rename(uri: 'file:///x.kap', **cursor_at(text, 'counter'), new_name: 'total')
612
+ )
613
+ changes = result_for(responses)['result']['documentChanges']
614
+
615
+ expect(changes.length).to eq(1)
616
+ edits = changes.first['edits']
617
+ expect(edits.length).to eq(1)
618
+ expect(edits.first['newText']).to eq('total')
619
+ expect(edits.first['range']['start']).to eq('line' => 1, 'character' => 21)
620
+ end
621
+
622
+ it 'renames a $gvar within a file' do
623
+ text = "(set $last 1)\n(print $last)\n"
624
+ responses = run(
625
+ frame_initialize,
626
+ frame_did_open('file:///x.kap', text),
627
+ frame_rename(uri: 'file:///x.kap', **cursor_at(text, 'last'), new_name: 'latest')
628
+ )
629
+ changes = result_for(responses)['result']['documentChanges']
630
+
631
+ expect(changes.length).to eq(1)
632
+ edits = changes.first['edits']
633
+ expect(edits.map { |e| e['newText'] }).to eq(%w[latest latest])
634
+ end
635
+
636
+ it 'keeps @x and @@x in separate sigil namespaces' do
637
+ text = "(class C)\n(set @@flag 1)\n(fn show [] (set @flag 2))\n"
638
+ ivar_position = { line: 2, character: 18 }
639
+ responses = run(
640
+ frame_initialize,
641
+ frame_did_open('file:///x.kap', text),
642
+ frame_rename(uri: 'file:///x.kap', **ivar_position, new_name: 'mark')
643
+ )
644
+ changes = result_for(responses)['result']['documentChanges']
645
+
646
+ expect(changes.length).to eq(1)
647
+ edits = changes.first['edits']
648
+ expect(edits.length).to eq(1)
649
+ expect(edits.first['newText']).to eq('mark')
650
+ expect(edits.first['range']['start']).to eq('line' => 2, 'character' => 18)
651
+ end
652
+
653
+ it 'jumps to the first @@cvar binding from a later use site' do
654
+ text = "(class C)\n(set @@total 0)\n(fn add [] (set @@total (+ @@total 1)))\n"
655
+ use_index = text.rindex('@@total') + 2
656
+ prefix = text[0...use_index]
657
+ last_nl = prefix.rindex("\n")
658
+ pos = { line: prefix.count("\n"), character: last_nl ? use_index - last_nl - 1 : use_index }
659
+
660
+ responses = run(
661
+ frame_initialize,
662
+ frame_did_open('file:///x.kap', text),
663
+ frame_definition(uri: 'file:///x.kap', **pos)
664
+ )
665
+ result = result_for(responses)['result']
666
+
667
+ expect(result).to eq(
668
+ 'uri' => 'file:///x.kap',
669
+ 'range' => {
670
+ 'start' => { 'line' => 1, 'character' => 7 },
671
+ 'end' => { 'line' => 1, 'character' => 12 }
672
+ }
673
+ )
674
+ end
675
+
676
+ it 'escapes file URIs built during workspace scans' do
677
+ Dir.mktmpdir do |dir|
678
+ nested = File.join(dir, 'space dir')
679
+ Dir.mkdir(nested)
680
+ File.write(File.join(nested, 'a b#c.kap'), "(fn greet [] 1)\n")
681
+
682
+ index = Kapusta::LSP::WorkspaceIndex.new(roots: [nested]).scan!
683
+ uri = index.toplevel_fn_occurrences('greet').keys.first
684
+
685
+ expect(uri).to include('space%20dir')
686
+ expect(uri).to include('a%20b%23c.kap')
687
+ end
688
+ end
83
689
  end