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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rename'
4
+
5
+ module Kapusta
6
+ class LSP
7
+ module Definition
8
+ module_function
9
+
10
+ def find(uri, text, line_zero, character, workspace_index:)
11
+ target = Rename.locate(text, line_zero, character)
12
+ return unless target
13
+
14
+ case target.kind
15
+ when :local, :toplevel_fn, :constant
16
+ location_for_binding(uri, target.binding) if target.binding
17
+ when :macro
18
+ locations_for_macro(uri, target.binding, workspace_index)
19
+ when :free_toplevel
20
+ locations_for_toplevel(target.name, workspace_index)
21
+ when :free_constant
22
+ locations_for_constant(target.segment_prefix, workspace_index)
23
+ end
24
+ end
25
+
26
+ def locations_for_macro(uri, binding, workspace_index)
27
+ return unless binding
28
+
29
+ case binding.kind
30
+ when :macro
31
+ location_for_binding(uri, binding)
32
+ when :macro_import
33
+ def_uri, def_binding = workspace_index.find_macro_definition(
34
+ uri, binding.import_module, binding.import_key
35
+ )
36
+ location_for_binding(def_uri, def_binding) if def_uri && def_binding
37
+ end
38
+ end
39
+
40
+ def location_for_binding(uri, binding)
41
+ { uri:, range: binding_range(binding) }
42
+ end
43
+
44
+ def locations_for_toplevel(name, workspace_index)
45
+ defs = workspace_index.toplevel_fn_definitions(name)
46
+ return if defs.empty?
47
+
48
+ defs.map { |uri, b| { uri:, range: binding_range(b) } }
49
+ end
50
+
51
+ def locations_for_constant(prefix, workspace_index)
52
+ defs = workspace_index.constant_definitions_with_prefix(prefix)
53
+ return if defs.empty?
54
+
55
+ defs.map { |uri, b| { uri:, range: binding_range(b) } }
56
+ end
57
+
58
+ def binding_range(binding)
59
+ line = binding.line - 1
60
+ {
61
+ start: { line:, character: binding.column - 1 },
62
+ end: { line:, character: binding.end_column - 1 }
63
+ }
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kapusta
4
+ class LSP
5
+ module Diagnostics
6
+ SEVERITY_ERROR = 1
7
+
8
+ module_function
9
+
10
+ def collect(text, path)
11
+ Kapusta.compile(text, path: path || '(buffer)')
12
+ []
13
+ rescue Kapusta::Error => e
14
+ [diagnostic_from(e, text)]
15
+ end
16
+
17
+ def diagnostic_from(error, text)
18
+ line = [(error.line || 1) - 1, 0].max
19
+ column = [(error.column || 1) - 1, 0].max
20
+
21
+ {
22
+ range: {
23
+ start: { line:, character: column },
24
+ end: { line:, character: column + token_length(text, line, column) }
25
+ },
26
+ severity: SEVERITY_ERROR,
27
+ source: 'kapusta-ls',
28
+ message: error.reason
29
+ }
30
+ end
31
+
32
+ def token_length(text, line, column)
33
+ source_line = text.lines[line]
34
+ return 1 unless source_line
35
+
36
+ tail = source_line[column..] || ''
37
+ match = tail.match(/\A[^\s()\[\]{}";`,]+/)
38
+ match && match[0].length.positive? ? match[0].length : 1
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../formatter'
4
+
5
+ module Kapusta
6
+ class LSP
7
+ module Formatting
8
+ module_function
9
+
10
+ def text_edits(text, path)
11
+ formatted = Kapusta::Formatter.format(text, path:)
12
+ return [] if formatted == text
13
+
14
+ [{ range: full_range(text), newText: formatted }]
15
+ rescue Kapusta::Error
16
+ []
17
+ end
18
+
19
+ def full_range(text)
20
+ lines = text.split("\n", -1)
21
+ end_line = [lines.length - 1, 0].max
22
+ end_character = lines.last ? lines.last.length : 0
23
+ {
24
+ start: { line: 0, character: 0 },
25
+ end: { line: end_line, character: end_character }
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../compiler'
4
+
5
+ module Kapusta
6
+ class LSP
7
+ module Identifier
8
+ DELIM_CHARS = '()[]{}";`,'
9
+
10
+ module_function
11
+
12
+ def valid_local?(name)
13
+ return false if name.nil? || name.empty?
14
+ return false if name.match?(/\s/)
15
+ return false if name.match?(/[#{Regexp.escape(DELIM_CHARS)}]/o)
16
+ return false if name.match?(/\A-?\d/)
17
+ return false if name.include?('.')
18
+ return false if Kapusta::Compiler::SPECIAL_FORMS.include?(name)
19
+
20
+ true
21
+ end
22
+
23
+ def valid_constant_segment?(segment)
24
+ !segment.nil? && segment.match?(/\A[A-Z]\w*\z/)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,417 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../reader'
4
+ require_relative '../compiler'
5
+ require_relative 'scope_walker'
6
+ require_relative 'identifier'
7
+
8
+ module Kapusta
9
+ class LSP
10
+ module Rename
11
+ RESPONSE_REQUEST_FAILED = -32_803
12
+
13
+ Target = Struct.new(:kind, :sym, :name, :segment_index, :segment_prefix,
14
+ :seg_start, :seg_end, :binding, :walker, keyword_init: true)
15
+
16
+ module_function
17
+
18
+ def prepare(text, line_zero, character)
19
+ target = locate(text, line_zero, character)
20
+ return unless target
21
+
22
+ {
23
+ range: lsp_range(target.sym.line, target.seg_start, target.seg_end),
24
+ placeholder: placeholder_for(target)
25
+ }
26
+ end
27
+
28
+ def perform(uri, text, line_zero, character, new_name, workspace_index: nil)
29
+ target = locate(text, line_zero, character)
30
+ return error('rename not available at this position') unless target
31
+
32
+ case target.kind
33
+ when :local
34
+ rename_local(uri, target, new_name)
35
+ when :toplevel_fn, :free_toplevel
36
+ return error('cross-file rename requires a workspace') unless workspace_index
37
+
38
+ rename_toplevel(target, new_name, workspace_index)
39
+ when :constant, :free_constant
40
+ return error('cross-file rename requires a workspace') unless workspace_index
41
+
42
+ rename_constant(target, new_name, workspace_index)
43
+ when :macro
44
+ return error('cross-file rename requires a workspace') unless workspace_index
45
+
46
+ rename_macro(uri, target, new_name, workspace_index)
47
+ else
48
+ error("rename not supported for #{target.kind}")
49
+ end
50
+ end
51
+
52
+ def locate(text, line_zero, character)
53
+ forms = parse(text)
54
+ return unless forms
55
+
56
+ walker = ScopeWalker.analyze(forms)
57
+ line = line_zero + 1
58
+ col = character + 1
59
+
60
+ sym = sym_at_cursor(walker, line, col)
61
+ return unless sym
62
+ return if synthetic?(sym)
63
+
64
+ seg = segment_at_column(sym, col)
65
+ return unless seg && seg[:index] != :on_dot
66
+
67
+ binding = walker.bindings.find { |b| b.sym.equal?(sym) }
68
+ reference = walker.references.find { |r| r.sym.equal?(sym) }
69
+
70
+ classify(walker, sym, binding, reference, seg)
71
+ end
72
+
73
+ def parse(text)
74
+ Reader.read_all(text)
75
+ rescue Kapusta::Error
76
+ nil
77
+ end
78
+
79
+ def sym_at_cursor(walker, line, col)
80
+ candidates = []
81
+ walker.bindings.each do |b|
82
+ candidates << b.sym if b.line == line && col >= b.column && col <= b.end_column
83
+ end
84
+ walker.references.each do |r|
85
+ candidates << r.sym if r.line == line && col >= r.column && col <= r.end_column
86
+ end
87
+ candidates.first
88
+ end
89
+
90
+ def synthetic?(sym)
91
+ return true if sym.is_a?(MacroSym) || sym.is_a?(AutoGensym)
92
+
93
+ name = sym.name
94
+ name == '_' || name == '&' || name == '...' ||
95
+ name == '$' || name == '$...' || name.match?(/\A\$\d\z/)
96
+ end
97
+
98
+ def segment_at_column(sym, col)
99
+ unless sym.dotted?
100
+ start_col = sym.column
101
+ end_col = sym.column + sym.name.length
102
+ return unless col.between?(start_col, end_col)
103
+
104
+ return { index: 0, start: start_col, end: end_col }
105
+ end
106
+
107
+ pos = sym.column
108
+ segments = sym.segments
109
+ segments.each_with_index do |seg, k|
110
+ seg_start = pos
111
+ seg_end = pos + seg.length
112
+ return { index: k, start: seg_start, end: seg_end } if col >= seg_start && col < seg_end
113
+
114
+ if k < segments.length - 1
115
+ return { index: :on_dot, start: seg_end, end: seg_end + 1 } if col == seg_end
116
+ elsif col == seg_end
117
+ return { index: k, start: seg_start, end: seg_end }
118
+ end
119
+ pos = seg_end + 1
120
+ end
121
+ nil
122
+ end
123
+
124
+ def classify(walker, sym, binding, reference, seg)
125
+ if sym.dotted? && seg[:index].positive?
126
+ segment_text = sym.segments[seg[:index]]
127
+ return if segment_text.match?(/\A[a-z]/)
128
+ end
129
+
130
+ if binding
131
+ return if binding.kind == :method
132
+
133
+ return constant_target(walker, binding, seg) if %i[module class].include?(binding.kind)
134
+
135
+ return local_target(walker, binding, seg)
136
+ end
137
+
138
+ if reference
139
+ target = reference.target
140
+ if target
141
+ return if target.kind == :method
142
+ return constant_target(walker, target, seg, sym:) if %i[module class].include?(target.kind)
143
+
144
+ return local_target(walker, target, seg, sym:)
145
+ end
146
+ end
147
+
148
+ first_seg = sym.dotted? ? sym.segments.first : sym.name
149
+ if first_seg.match?(/\A[A-Z]/)
150
+ Target.new(
151
+ kind: :free_constant, sym:, name: sym.name,
152
+ segment_index: seg[:index], segment_prefix: (sym.dotted? ? sym.segments[0..seg[:index]] : [sym.name]),
153
+ seg_start: seg[:start], seg_end: seg[:end], walker:
154
+ )
155
+ else
156
+ Target.new(
157
+ kind: :free_toplevel, sym:, name: sym.name,
158
+ segment_index: seg[:index], seg_start: seg[:start], seg_end: seg[:end], walker:
159
+ )
160
+ end
161
+ end
162
+
163
+ def local_target(walker, binding, seg, sym: nil)
164
+ kind = case binding.kind
165
+ when :toplevel_fn then :toplevel_fn
166
+ when :macro, :macro_import then :macro
167
+ else :local
168
+ end
169
+ Target.new(
170
+ kind:,
171
+ sym: sym || binding.sym,
172
+ name: binding.name,
173
+ segment_index: seg[:index],
174
+ seg_start: seg[:start],
175
+ seg_end: seg[:end],
176
+ binding:,
177
+ walker:
178
+ )
179
+ end
180
+
181
+ def constant_target(walker, binding, seg, sym: nil)
182
+ the_sym = sym || binding.sym
183
+ prefix = the_sym.dotted? ? the_sym.segments[0..seg[:index]] : [the_sym.name]
184
+ Target.new(
185
+ kind: :constant,
186
+ sym: the_sym,
187
+ name: binding.name,
188
+ segment_index: seg[:index],
189
+ segment_prefix: prefix,
190
+ seg_start: seg[:start],
191
+ seg_end: seg[:end],
192
+ binding:,
193
+ walker:
194
+ )
195
+ end
196
+
197
+ def placeholder_for(target)
198
+ return target.segment_prefix.last if target.segment_prefix
199
+
200
+ target.name
201
+ end
202
+
203
+ def lsp_range(line, start_col, end_col)
204
+ {
205
+ start: { line: line - 1, character: start_col - 1 },
206
+ end: { line: line - 1, character: end_col - 1 }
207
+ }
208
+ end
209
+
210
+ def rename_local(uri, target, new_name)
211
+ return error("invalid identifier: #{new_name}") unless Identifier.valid_local?(new_name)
212
+ return error('cannot resolve binding') unless target.binding
213
+
214
+ walker = target.walker
215
+ binding = target.binding
216
+ scope = binding.scope
217
+
218
+ edits_targets = collect_local_targets(walker, binding)
219
+ if conflict_local?(scope, new_name, edits_targets, walker, binding)
220
+ return error("rename conflict: '#{new_name}' is already in scope")
221
+ end
222
+
223
+ edits = edits_targets.map { |t| text_edit_first_segment(t, new_name) }
224
+ { changes: { uri => edits } }
225
+ end
226
+
227
+ def collect_local_targets(walker, binding)
228
+ scope = binding.scope
229
+ results = []
230
+ walker.bindings.each do |b|
231
+ results << b if b.name == binding.name && b.scope.equal?(scope)
232
+ end
233
+ walker.references.each do |r|
234
+ results << r if r.target.equal?(binding)
235
+ end
236
+ results
237
+ end
238
+
239
+ def conflict_local?(scope, new_name, targets, _walker, binding)
240
+ return true if scope.bindings[new_name] && !scope.bindings[new_name].equal?(binding)
241
+
242
+ targets.any? do |t|
243
+ next false unless t.is_a?(ScopeWalker::Reference)
244
+
245
+ shadowed_in_chain?(t.scope, new_name, scope)
246
+ end
247
+ end
248
+
249
+ def shadowed_in_chain?(ref_scope, new_name, target_scope)
250
+ s = ref_scope
251
+ while s && !s.equal?(target_scope)
252
+ return true if s.bindings.key?(new_name)
253
+
254
+ s = s.parent
255
+ end
256
+ false
257
+ end
258
+
259
+ def rename_toplevel(target, new_name, workspace_index)
260
+ return error("invalid identifier: #{new_name}") unless Identifier.valid_local?(new_name)
261
+
262
+ per_uri = workspace_index.toplevel_fn_occurrences(target.name)
263
+ return error('no occurrences found') if per_uri.empty?
264
+
265
+ if workspace_index.toplevel_definition?(new_name, except_name: target.name)
266
+ return error("rename conflict: '#{new_name}' is already defined in the workspace")
267
+ end
268
+
269
+ changes = per_uri.transform_values do |occs|
270
+ occs.map { |o| text_edit_first_segment(o, new_name) }
271
+ end
272
+ { changes: }
273
+ end
274
+
275
+ def rename_macro(uri, target, new_name, workspace_index)
276
+ return error("invalid identifier: #{new_name}") unless Identifier.valid_local?(new_name)
277
+ return error('cannot resolve binding') unless target.binding
278
+
279
+ def_uri, def_binding = locate_macro_definition(uri, target.binding, workspace_index)
280
+ return error('macro definition not found') unless def_uri && def_binding
281
+
282
+ if workspace_index.macro_definition_anywhere?(new_name, except_uri: def_uri) ||
283
+ macro_defined_in_file?(workspace_index.entry(def_uri), new_name, except: def_binding)
284
+ return error("rename conflict: macro '#{new_name}' is already defined in the workspace")
285
+ end
286
+
287
+ changes = collect_macro_changes(def_uri, def_binding, new_name, workspace_index)
288
+ return error('no occurrences found') if changes.empty?
289
+
290
+ { changes: }
291
+ end
292
+
293
+ def locate_macro_definition(uri, binding, workspace_index)
294
+ case binding.kind
295
+ when :macro
296
+ entry = workspace_index.entry(uri)
297
+ return unless entry
298
+
299
+ indexed = entry.walker.bindings.find { |b| b.kind == :macro && b.name == binding.name }
300
+ indexed ? [uri, indexed] : nil
301
+ when :macro_import
302
+ workspace_index.find_macro_definition(uri, binding.import_module, binding.import_key)
303
+ end
304
+ end
305
+
306
+ def macro_defined_in_file?(entry, name, except:)
307
+ return false unless entry
308
+
309
+ entry.walker.bindings.any? do |b|
310
+ b.kind == :macro && b.name == name && !b.equal?(except)
311
+ end
312
+ end
313
+
314
+ def collect_macro_changes(def_uri, def_binding, new_name, workspace_index)
315
+ changes = {}
316
+
317
+ def_entry = workspace_index.entry(def_uri)
318
+ if def_entry
319
+ targets = def_entry.walker.bindings.select { |b| b.equal?(def_binding) }
320
+ targets += def_entry.walker.references.select do |r|
321
+ r.target.equal?(def_binding) || (r.target.nil? && r.name == def_binding.name)
322
+ end
323
+ changes[def_uri] = targets.map { |t| text_edit_first_segment(t, new_name) } unless targets.empty?
324
+ end
325
+
326
+ workspace_index.each_entry do |uri, entry|
327
+ next if uri == def_uri
328
+
329
+ imports = entry.walker.bindings.select do |b|
330
+ next false unless b.kind == :macro_import
331
+ next false unless b.import_key.to_s.tr('_', '-') == def_binding.name
332
+
333
+ workspace_index.import_resolves_to?(uri, b.import_module, def_uri)
334
+ end
335
+ next if imports.empty?
336
+
337
+ refs = entry.walker.references.select do |r|
338
+ imports.any? { |imp| imp.equal?(r.target) }
339
+ end
340
+ changes[uri] = (imports + refs).map { |t| text_edit_first_segment(t, new_name) }
341
+ end
342
+
343
+ changes
344
+ end
345
+
346
+ def rename_constant(target, new_name, workspace_index)
347
+ return error("invalid constant segment: #{new_name}") unless Identifier.valid_constant_segment?(new_name)
348
+
349
+ prefix = target.segment_prefix
350
+ seg_index = target.segment_index
351
+ per_uri = workspace_index.constant_occurrences(prefix)
352
+ return error('no occurrences found') if per_uri.empty?
353
+
354
+ new_prefix = prefix.dup
355
+ new_prefix[seg_index] = new_name
356
+ if workspace_index.constant_definition_with_prefix?(new_prefix, except_prefix: prefix)
357
+ return error("rename conflict: constant '#{new_prefix.join('.')}' is already defined")
358
+ end
359
+
360
+ changes = {}
361
+ per_uri.each do |uri, occs|
362
+ changes[uri] = occs.map do |occ|
363
+ seg_start, seg_end = segment_range(occ.sym, seg_index)
364
+ {
365
+ range: {
366
+ start: { line: occ.line - 1, character: seg_start - 1 },
367
+ end: { line: occ.line - 1, character: seg_end - 1 }
368
+ },
369
+ newText: new_name
370
+ }
371
+ end
372
+ end
373
+ { changes: }
374
+ end
375
+
376
+ def segment_range(sym, segment_index)
377
+ return [sym.column, sym.column + sym.name.length] unless sym.dotted?
378
+
379
+ segments = sym.segments
380
+ prior = segments[0...segment_index].sum { |s| s.length + 1 }
381
+ start_col = sym.column + prior
382
+ [start_col, start_col + segments[segment_index].length]
383
+ end
384
+
385
+ def text_edit_full(occurrence, new_name)
386
+ line = occurrence.line - 1
387
+ start_col = occurrence.column - 1
388
+ end_col = occurrence.end_column - 1
389
+ {
390
+ range: {
391
+ start: { line:, character: start_col },
392
+ end: { line:, character: end_col }
393
+ },
394
+ newText: new_name
395
+ }
396
+ end
397
+
398
+ def text_edit_first_segment(occurrence, new_name)
399
+ sym = occurrence.sym
400
+ return text_edit_full(occurrence, new_name) unless sym.is_a?(Sym) && sym.dotted?
401
+
402
+ seg_start, seg_end = segment_range(sym, 0)
403
+ {
404
+ range: {
405
+ start: { line: occurrence.line - 1, character: seg_start - 1 },
406
+ end: { line: occurrence.line - 1, character: seg_end - 1 }
407
+ },
408
+ newText: new_name
409
+ }
410
+ end
411
+
412
+ def error(message)
413
+ { error: { code: RESPONSE_REQUEST_FAILED, message: } }
414
+ end
415
+ end
416
+ end
417
+ end