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