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.
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,473 @@ 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 'escapes file URIs built during workspace scans' do
591
+ Dir.mktmpdir do |dir|
592
+ nested = File.join(dir, 'space dir')
593
+ Dir.mkdir(nested)
594
+ File.write(File.join(nested, 'a b#c.kap'), "(fn greet [] 1)\n")
595
+
596
+ index = Kapusta::LSP::WorkspaceIndex.new(roots: [nested]).scan!
597
+ uri = index.toplevel_fn_occurrences('greet').keys.first
598
+
599
+ expect(uri).to include('space%20dir')
600
+ expect(uri).to include('a%20b%23c.kap')
601
+ end
602
+ end
83
603
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kapusta
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Evgenii Morozov
@@ -57,10 +57,14 @@ files:
57
57
  - examples/greet.kap
58
58
  - examples/happy-number.kap
59
59
  - examples/hashfn.kap
60
+ - examples/import-helpers.kapm
60
61
  - examples/kwargs.kap
61
62
  - examples/leap-year.kap
62
63
  - examples/length-of-last-word.kap
63
64
  - examples/macros-dbg.kap
65
+ - examples/macros-import-helpers.kap
66
+ - examples/macros-import-whole.kap
67
+ - examples/macros-import.kap
64
68
  - examples/macros-multi.kap
65
69
  - examples/macros-swap.kap
66
70
  - examples/macros-thrice-if.kap
@@ -93,6 +97,7 @@ files:
93
97
  - examples/safe-lookup.kap
94
98
  - examples/scopes.kap
95
99
  - examples/shapes.kap
100
+ - examples/shared-macros.kapm
96
101
  - examples/single-number.kap
97
102
  - examples/squares.kap
98
103
  - examples/stack.kap
@@ -128,12 +133,22 @@ files:
128
133
  - lib/kapusta/compiler/emitter/support.rb
129
134
  - lib/kapusta/compiler/lua_compat.rb
130
135
  - lib/kapusta/compiler/macro_expander.rb
136
+ - lib/kapusta/compiler/macro_gensym.rb
137
+ - lib/kapusta/compiler/macro_importer.rb
138
+ - lib/kapusta/compiler/macro_lowerer.rb
131
139
  - lib/kapusta/compiler/normalizer.rb
132
140
  - lib/kapusta/env.rb
133
141
  - lib/kapusta/error.rb
134
142
  - lib/kapusta/errors.rb
135
143
  - lib/kapusta/formatter.rb
136
144
  - lib/kapusta/lsp.rb
145
+ - lib/kapusta/lsp/definition.rb
146
+ - lib/kapusta/lsp/diagnostics.rb
147
+ - lib/kapusta/lsp/formatting.rb
148
+ - lib/kapusta/lsp/identifier.rb
149
+ - lib/kapusta/lsp/rename.rb
150
+ - lib/kapusta/lsp/scope_walker.rb
151
+ - lib/kapusta/lsp/workspace_index.rb
137
152
  - lib/kapusta/reader.rb
138
153
  - lib/kapusta/support.rb
139
154
  - lib/kapusta/version.rb