kapusta 0.5.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +24 -6
  3. data/bin/fennel-parity +11 -4
  4. data/examples/classify-wallet.kap +11 -0
  5. data/examples/import-helpers.kapm +9 -0
  6. data/examples/macros-import-helpers.kap +3 -0
  7. data/examples/macros-import-whole.kap +5 -0
  8. data/examples/macros-import.kap +6 -0
  9. data/examples/power-of-three.kap +12 -0
  10. data/examples/shared-macros.kapm +4 -0
  11. data/exe/kapusta-ls +14 -0
  12. data/kapusta.gemspec +2 -2
  13. data/lib/kapusta/compiler/emitter/bindings.rb +38 -4
  14. data/lib/kapusta/compiler/emitter/collections.rb +51 -59
  15. data/lib/kapusta/compiler/emitter/control_flow.rb +24 -2
  16. data/lib/kapusta/compiler/emitter/expressions.rb +0 -2
  17. data/lib/kapusta/compiler/emitter/interop.rb +2 -1
  18. data/lib/kapusta/compiler/emitter/patterns.rb +52 -4
  19. data/lib/kapusta/compiler/emitter/support.rb +1 -1
  20. data/lib/kapusta/compiler/emitter.rb +1 -1
  21. data/lib/kapusta/compiler/lua_compat.rb +149 -0
  22. data/lib/kapusta/compiler/macro_expander.rb +55 -141
  23. data/lib/kapusta/compiler/macro_gensym.rb +21 -0
  24. data/lib/kapusta/compiler/macro_importer.rb +81 -0
  25. data/lib/kapusta/compiler/macro_lowerer.rb +184 -0
  26. data/lib/kapusta/compiler/normalizer.rb +4 -19
  27. data/lib/kapusta/compiler.rb +4 -2
  28. data/lib/kapusta/errors.rb +9 -3
  29. data/lib/kapusta/formatter.rb +4 -0
  30. data/lib/kapusta/lsp/definition.rb +67 -0
  31. data/lib/kapusta/lsp/diagnostics.rb +42 -0
  32. data/lib/kapusta/lsp/formatting.rb +30 -0
  33. data/lib/kapusta/lsp/identifier.rb +28 -0
  34. data/lib/kapusta/lsp/rename.rb +417 -0
  35. data/lib/kapusta/lsp/scope_walker.rb +643 -0
  36. data/lib/kapusta/lsp/workspace_index.rb +225 -0
  37. data/lib/kapusta/lsp.rb +312 -0
  38. data/lib/kapusta/reader.rb +0 -2
  39. data/lib/kapusta/version.rb +1 -1
  40. data/spec/examples_errors_spec.rb +142 -1
  41. data/spec/examples_spec.rb +12 -0
  42. data/spec/lsp_spec.rb +603 -0
  43. metadata +23 -1
data/spec/lsp_spec.rb ADDED
@@ -0,0 +1,603 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'kapusta/lsp'
5
+ require 'json'
6
+ require 'stringio'
7
+ require 'tmpdir'
8
+
9
+ RSpec.describe Kapusta::LSP do
10
+ def frame(payload)
11
+ body = JSON.generate(payload)
12
+ "Content-Length: #{body.bytesize}\r\n\r\n#{body}"
13
+ end
14
+
15
+ def parse_responses(stdout)
16
+ messages = []
17
+ rest = stdout.dup
18
+ while (m = rest.match(/\AContent-Length: (\d+)\r\n\r\n/))
19
+ len = Integer(m[1], 10)
20
+ messages << JSON.parse(rest[m[0].length, len])
21
+ rest = rest[(m[0].length + len)..]
22
+ end
23
+ messages
24
+ end
25
+
26
+ def run(*frames)
27
+ input = StringIO.new(frames.join)
28
+ output = StringIO.new
29
+ log = StringIO.new
30
+ described_class.new(input:, output:, log:).run
31
+ parse_responses(output.string)
32
+ end
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
+
88
+ it 'advertises diagnostics and formatting capabilities on initialize' do
89
+ responses = run(frame_initialize)
90
+ capabilities = responses.first.dig('result', 'capabilities')
91
+
92
+ expect(capabilities).to include('textDocumentSync', 'documentFormattingProvider')
93
+ expect(capabilities['definitionProvider']).to be(true)
94
+ end
95
+
96
+ it 'publishes diagnostics for invalid source' do
97
+ responses = run(
98
+ frame_initialize,
99
+ frame_did_open('file:///x.kap', '(let [x 1] (+ x ()))')
100
+ )
101
+
102
+ expect(responses.last.dig('params', 'diagnostics')).not_to be_empty
103
+ end
104
+
105
+ it 'publishes no diagnostics for valid source' do
106
+ responses = run(
107
+ frame_initialize,
108
+ frame_did_open('file:///x.kap', '(print "hi")')
109
+ )
110
+
111
+ expect(responses.last.dig('params', 'diagnostics')).to be_empty
112
+ end
113
+
114
+ it 'returns a TextEdit for formatting' do
115
+ responses = run(
116
+ frame_initialize,
117
+ frame_did_open('file:///x.kap', "(fn greet [x] (print x))\n"),
118
+ frame_formatting(uri: 'file:///x.kap')
119
+ )
120
+ edits = result_for(responses)['result']
121
+
122
+ expect(edits).to be_an(Array).and(be_one)
123
+ expect(edits.first).to include('range', 'newText')
124
+ end
125
+
126
+ it 'rejects requests sent before initialize' do
127
+ responses = run(
128
+ frame(jsonrpc: '2.0', id: 1, method: 'textDocument/formatting',
129
+ params: { textDocument: { uri: 'file:///x.kap' } })
130
+ )
131
+
132
+ expect(responses.first.dig('error', 'code')).to eq(-32_002)
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
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.5.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Evgenii Morozov
@@ -13,6 +13,7 @@ description: Kapusta is a Lisp for the Ruby runtime.
13
13
  executables:
14
14
  - kapfmt
15
15
  - kapusta
16
+ - kapusta-ls
16
17
  extensions: []
17
18
  extra_rdoc_files: []
18
19
  files:
@@ -37,6 +38,7 @@ files:
37
38
  - examples/block-sort.kap
38
39
  - examples/blocks-and-kwargs.kap
39
40
  - examples/calc.kap
41
+ - examples/classify-wallet.kap
40
42
  - examples/climbing-stairs.kap
41
43
  - examples/contains-duplicate.kap
42
44
  - examples/counter.kap
@@ -55,10 +57,14 @@ files:
55
57
  - examples/greet.kap
56
58
  - examples/happy-number.kap
57
59
  - examples/hashfn.kap
60
+ - examples/import-helpers.kapm
58
61
  - examples/kwargs.kap
59
62
  - examples/leap-year.kap
60
63
  - examples/length-of-last-word.kap
61
64
  - examples/macros-dbg.kap
65
+ - examples/macros-import-helpers.kap
66
+ - examples/macros-import-whole.kap
67
+ - examples/macros-import.kap
62
68
  - examples/macros-multi.kap
63
69
  - examples/macros-swap.kap
64
70
  - examples/macros-thrice-if.kap
@@ -80,6 +86,7 @@ files:
80
86
  - examples/pivot-index.kap
81
87
  - examples/plus-one.kap
82
88
  - examples/points.kap
89
+ - examples/power-of-three.kap
83
90
  - examples/primes.kap
84
91
  - examples/raindrops.kap
85
92
  - examples/record.kap
@@ -90,6 +97,7 @@ files:
90
97
  - examples/safe-lookup.kap
91
98
  - examples/scopes.kap
92
99
  - examples/shapes.kap
100
+ - examples/shared-macros.kapm
93
101
  - examples/single-number.kap
94
102
  - examples/squares.kap
95
103
  - examples/stack.kap
@@ -109,6 +117,7 @@ files:
109
117
  - examples/zoo-animal-inheritance-2.kap
110
118
  - exe/kapfmt
111
119
  - exe/kapusta
120
+ - exe/kapusta-ls
112
121
  - kapusta.gemspec
113
122
  - lib/kapusta.rb
114
123
  - lib/kapusta/ast.rb
@@ -122,12 +131,24 @@ files:
122
131
  - lib/kapusta/compiler/emitter/interop.rb
123
132
  - lib/kapusta/compiler/emitter/patterns.rb
124
133
  - lib/kapusta/compiler/emitter/support.rb
134
+ - lib/kapusta/compiler/lua_compat.rb
125
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
126
139
  - lib/kapusta/compiler/normalizer.rb
127
140
  - lib/kapusta/env.rb
128
141
  - lib/kapusta/error.rb
129
142
  - lib/kapusta/errors.rb
130
143
  - lib/kapusta/formatter.rb
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
131
152
  - lib/kapusta/reader.rb
132
153
  - lib/kapusta/support.rb
133
154
  - lib/kapusta/version.rb
@@ -135,6 +156,7 @@ files:
135
156
  - spec/examples_errors_spec.rb
136
157
  - spec/examples_spec.rb
137
158
  - spec/formatter_spec.rb
159
+ - spec/lsp_spec.rb
138
160
  - spec/spec_helper.rb
139
161
  homepage: https://github.com/evmorov/kapusta
140
162
  licenses: