kapusta 0.4.1 → 0.7.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.
- checksums.yaml +4 -4
- data/README.md +24 -6
- data/bin/fennel-parity +38 -6
- data/examples/classify-wallet.kap +11 -0
- data/examples/even-squares.kap +22 -7
- data/examples/power-of-three.kap +12 -0
- data/examples/roman-to-integer.kap +3 -3
- data/exe/kapusta-ls +14 -0
- data/kapusta.gemspec +2 -2
- data/lib/kapusta/ast.rb +38 -4
- data/lib/kapusta/cli.rb +3 -0
- data/lib/kapusta/compiler/emitter/bindings.rb +90 -10
- data/lib/kapusta/compiler/emitter/collections.rb +85 -49
- data/lib/kapusta/compiler/emitter/control_flow.rb +31 -6
- data/lib/kapusta/compiler/emitter/expressions.rb +25 -16
- data/lib/kapusta/compiler/emitter/interop.rb +8 -5
- data/lib/kapusta/compiler/emitter/patterns.rb +74 -5
- data/lib/kapusta/compiler/emitter/support.rb +45 -23
- data/lib/kapusta/compiler/emitter.rb +1 -1
- data/lib/kapusta/compiler/lua_compat.rb +149 -0
- data/lib/kapusta/compiler/macro_expander.rb +57 -25
- data/lib/kapusta/compiler/normalizer.rb +39 -28
- data/lib/kapusta/compiler.rb +10 -4
- data/lib/kapusta/error.rb +25 -1
- data/lib/kapusta/errors.rb +70 -0
- data/lib/kapusta/formatter.rb +16 -5
- data/lib/kapusta/lsp.rb +258 -0
- data/lib/kapusta/reader.rb +33 -13
- data/lib/kapusta/version.rb +1 -1
- data/lib/kapusta.rb +1 -0
- data/spec/examples_errors_spec.rb +354 -0
- data/spec/formatter_spec.rb +7 -6
- data/spec/lsp_spec.rb +83 -0
- metadata +10 -2
- data/spec/reader_spec.rb +0 -26
data/lib/kapusta/lsp.rb
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require_relative '../kapusta'
|
|
6
|
+
require_relative 'formatter'
|
|
7
|
+
|
|
8
|
+
module Kapusta
|
|
9
|
+
class LSP
|
|
10
|
+
NOT_INITIALIZED = -32_002
|
|
11
|
+
METHOD_NOT_FOUND = -32_601
|
|
12
|
+
SEVERITY_ERROR = 1
|
|
13
|
+
FULL_SYNC = 1
|
|
14
|
+
|
|
15
|
+
def self.start(input: $stdin, output: $stdout, log: $stderr)
|
|
16
|
+
new(input:, output:, log:).run
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(input:, output:, log:)
|
|
20
|
+
@input = input.binmode
|
|
21
|
+
@output = output.binmode
|
|
22
|
+
@log = log
|
|
23
|
+
@sources = {}
|
|
24
|
+
@initialized = false
|
|
25
|
+
@shutdown = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run
|
|
29
|
+
until (message = read_message).nil?
|
|
30
|
+
handle(message)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def read_message
|
|
37
|
+
headers = read_headers
|
|
38
|
+
return if headers.nil?
|
|
39
|
+
|
|
40
|
+
raw_length = headers['Content-Length']
|
|
41
|
+
return if raw_length.nil?
|
|
42
|
+
|
|
43
|
+
length = Integer(raw_length, 10, exception: false)
|
|
44
|
+
return if length.nil? || length.negative?
|
|
45
|
+
|
|
46
|
+
body = @input.read(length)
|
|
47
|
+
return if body.nil?
|
|
48
|
+
|
|
49
|
+
JSON.parse(body.force_encoding(Encoding::UTF_8))
|
|
50
|
+
rescue JSON::ParserError => e
|
|
51
|
+
log("parse error: #{e.message}")
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def read_headers
|
|
56
|
+
headers = {}
|
|
57
|
+
loop do
|
|
58
|
+
line = @input.gets
|
|
59
|
+
return if line.nil?
|
|
60
|
+
break if line.chomp.empty?
|
|
61
|
+
|
|
62
|
+
name, value = line.chomp.split(': ', 2)
|
|
63
|
+
headers[name] = value if name && value
|
|
64
|
+
end
|
|
65
|
+
headers
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def write_message(payload)
|
|
69
|
+
body = JSON.generate(payload)
|
|
70
|
+
@output.write("Content-Length: #{body.bytesize}\r\n\r\n#{body}")
|
|
71
|
+
@output.flush
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def handle(message)
|
|
75
|
+
method = message['method']
|
|
76
|
+
id = message['id']
|
|
77
|
+
params = message['params'] || {}
|
|
78
|
+
|
|
79
|
+
return handle_pre_init(method, id, params) unless @initialized || method == 'initialize' || method == 'exit'
|
|
80
|
+
|
|
81
|
+
dispatch(method, id, params)
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
log("#{e.class}: #{e.message}")
|
|
84
|
+
log(e.backtrace.first(5).join("\n"))
|
|
85
|
+
reply_error(id, METHOD_NOT_FOUND, e.message)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def handle_pre_init(method, id, _params)
|
|
89
|
+
return if id.nil?
|
|
90
|
+
|
|
91
|
+
reply_error(id, NOT_INITIALIZED, "received #{method} before initialize")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def dispatch(method, id, params)
|
|
95
|
+
case method
|
|
96
|
+
when 'initialize'
|
|
97
|
+
@initialized = true
|
|
98
|
+
reply(id, initialize_result)
|
|
99
|
+
when 'initialized' then nil
|
|
100
|
+
when 'shutdown'
|
|
101
|
+
@shutdown = true
|
|
102
|
+
reply(id, nil)
|
|
103
|
+
when 'exit' then exit(@shutdown ? 0 : 1)
|
|
104
|
+
when 'textDocument/didOpen' then on_did_open(params)
|
|
105
|
+
when 'textDocument/didChange' then on_did_change(params)
|
|
106
|
+
when 'textDocument/didSave' then on_did_save(params)
|
|
107
|
+
when 'textDocument/didClose' then on_did_close(params)
|
|
108
|
+
when 'textDocument/formatting' then reply(id, formatting(params))
|
|
109
|
+
else
|
|
110
|
+
reply_error(id, METHOD_NOT_FOUND, "method not found: #{method}")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def reply(id, result)
|
|
115
|
+
return if id.nil?
|
|
116
|
+
|
|
117
|
+
write_message(jsonrpc: '2.0', id:, result:)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def reply_error(id, code, message)
|
|
121
|
+
return if id.nil?
|
|
122
|
+
|
|
123
|
+
write_message(jsonrpc: '2.0', id:, error: { code:, message: })
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def notify(method, params)
|
|
127
|
+
write_message(jsonrpc: '2.0', method:, params:)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def initialize_result
|
|
131
|
+
{
|
|
132
|
+
capabilities: {
|
|
133
|
+
textDocumentSync: { openClose: true, change: FULL_SYNC, save: { includeText: false } },
|
|
134
|
+
documentFormattingProvider: true
|
|
135
|
+
},
|
|
136
|
+
serverInfo: { name: 'kapusta-ls', version: Kapusta::VERSION }
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def on_did_open(params)
|
|
141
|
+
doc = params['textDocument'] || {}
|
|
142
|
+
uri = doc['uri']
|
|
143
|
+
return unless uri
|
|
144
|
+
|
|
145
|
+
version = doc['version']
|
|
146
|
+
text = doc['text'] || ''
|
|
147
|
+
store(uri, text, version)
|
|
148
|
+
publish_diagnostics(uri, text, version)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def on_did_change(params)
|
|
152
|
+
uri = params.dig('textDocument', 'uri')
|
|
153
|
+
version = params.dig('textDocument', 'version')
|
|
154
|
+
changes = params['contentChanges'] || []
|
|
155
|
+
return if uri.nil? || changes.empty?
|
|
156
|
+
|
|
157
|
+
text = changes.last['text']
|
|
158
|
+
store(uri, text, version)
|
|
159
|
+
publish_diagnostics(uri, text, version)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def on_did_save(params)
|
|
163
|
+
uri = params.dig('textDocument', 'uri')
|
|
164
|
+
entry = @sources[uri]
|
|
165
|
+
return unless entry
|
|
166
|
+
|
|
167
|
+
publish_diagnostics(uri, entry[:text], entry[:version])
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def on_did_close(params)
|
|
171
|
+
uri = params.dig('textDocument', 'uri')
|
|
172
|
+
return unless uri
|
|
173
|
+
|
|
174
|
+
@sources.delete(uri)
|
|
175
|
+
notify('textDocument/publishDiagnostics', { uri:, diagnostics: [] })
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def formatting(params)
|
|
179
|
+
uri = params.dig('textDocument', 'uri')
|
|
180
|
+
entry = @sources[uri]
|
|
181
|
+
return [] unless entry
|
|
182
|
+
|
|
183
|
+
formatted = Kapusta::Formatter.format(entry[:text], path: uri_to_path(uri))
|
|
184
|
+
return [] if formatted == entry[:text]
|
|
185
|
+
|
|
186
|
+
[{ range: full_range(entry[:text]), newText: formatted }]
|
|
187
|
+
rescue Kapusta::Error
|
|
188
|
+
[]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def store(uri, text, version)
|
|
192
|
+
@sources[uri] = { text:, version: }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def publish_diagnostics(uri, text, version)
|
|
196
|
+
diagnostics = collect_diagnostics(text, uri_to_path(uri))
|
|
197
|
+
params = { uri:, diagnostics: }
|
|
198
|
+
params[:version] = version unless version.nil?
|
|
199
|
+
notify('textDocument/publishDiagnostics', params)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def collect_diagnostics(text, path)
|
|
203
|
+
Kapusta.compile(text, path: path || '(buffer)')
|
|
204
|
+
[]
|
|
205
|
+
rescue Kapusta::Error => e
|
|
206
|
+
[diagnostic_from(e, text)]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def diagnostic_from(error, text)
|
|
210
|
+
line = [(error.line || 1) - 1, 0].max
|
|
211
|
+
column = [(error.column || 1) - 1, 0].max
|
|
212
|
+
|
|
213
|
+
{
|
|
214
|
+
range: {
|
|
215
|
+
start: { line:, character: column },
|
|
216
|
+
end: { line:, character: column + token_length(text, line, column) }
|
|
217
|
+
},
|
|
218
|
+
severity: SEVERITY_ERROR,
|
|
219
|
+
source: 'kapusta-ls',
|
|
220
|
+
message: error.reason
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def token_length(text, line, column)
|
|
225
|
+
source_line = text.lines[line]
|
|
226
|
+
return 1 unless source_line
|
|
227
|
+
|
|
228
|
+
tail = source_line[column..] || ''
|
|
229
|
+
match = tail.match(/\A[^\s()\[\]{}";`,]+/)
|
|
230
|
+
match && match[0].length.positive? ? match[0].length : 1
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def full_range(text)
|
|
234
|
+
lines = text.split("\n", -1)
|
|
235
|
+
end_line = [lines.length - 1, 0].max
|
|
236
|
+
end_character = lines.last ? lines.last.length : 0
|
|
237
|
+
{
|
|
238
|
+
start: { line: 0, character: 0 },
|
|
239
|
+
end: { line: end_line, character: end_character }
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def uri_to_path(uri)
|
|
244
|
+
return unless uri
|
|
245
|
+
|
|
246
|
+
parsed = URI.parse(uri)
|
|
247
|
+
return URI::DEFAULT_PARSER.unescape(parsed.path) if parsed.scheme == 'file'
|
|
248
|
+
|
|
249
|
+
uri
|
|
250
|
+
rescue URI::InvalidURIError
|
|
251
|
+
uri
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def log(message)
|
|
255
|
+
@log.puts "kapusta-ls: #{message}"
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
data/lib/kapusta/reader.rb
CHANGED
|
@@ -72,7 +72,7 @@ module Kapusta
|
|
|
72
72
|
|
|
73
73
|
def read_next_item
|
|
74
74
|
skip_ws
|
|
75
|
-
raise
|
|
75
|
+
raise reader_error(:unexpected_eof, source_position) if eof?
|
|
76
76
|
|
|
77
77
|
return read_comment if @preserve_comments && peek == ';'
|
|
78
78
|
|
|
@@ -81,10 +81,11 @@ module Kapusta
|
|
|
81
81
|
|
|
82
82
|
def read_form
|
|
83
83
|
skip_ws
|
|
84
|
-
raise
|
|
84
|
+
raise reader_error(:unexpected_eof, source_position) if eof?
|
|
85
85
|
|
|
86
86
|
return read_comment if @preserve_comments && peek == ';'
|
|
87
87
|
|
|
88
|
+
position = source_position
|
|
88
89
|
form =
|
|
89
90
|
case peek
|
|
90
91
|
when '(' then read_list
|
|
@@ -99,9 +100,18 @@ module Kapusta
|
|
|
99
100
|
read_atom
|
|
100
101
|
end
|
|
101
102
|
|
|
103
|
+
attach_position(form, position)
|
|
102
104
|
read_postfix(form)
|
|
103
105
|
end
|
|
104
106
|
|
|
107
|
+
def attach_position(form, position)
|
|
108
|
+
return form unless form.respond_to?(:line=)
|
|
109
|
+
|
|
110
|
+
form.line ||= position[0]
|
|
111
|
+
form.column ||= position[1]
|
|
112
|
+
form
|
|
113
|
+
end
|
|
114
|
+
|
|
105
115
|
def read_quasiquote
|
|
106
116
|
advance
|
|
107
117
|
Quasiquote.new(read_form)
|
|
@@ -133,6 +143,8 @@ module Kapusta
|
|
|
133
143
|
advance
|
|
134
144
|
list = List.new(items)
|
|
135
145
|
list.multiline_source = closing_position[0] != opening_position[0]
|
|
146
|
+
list.line = opening_position[0]
|
|
147
|
+
list.column = opening_position[1]
|
|
136
148
|
list
|
|
137
149
|
end
|
|
138
150
|
|
|
@@ -152,6 +164,8 @@ module Kapusta
|
|
|
152
164
|
advance
|
|
153
165
|
vec = Vec.new(items)
|
|
154
166
|
vec.multiline_source = closing_position[0] != opening_position[0]
|
|
167
|
+
vec.line = opening_position[0]
|
|
168
|
+
vec.column = opening_position[1]
|
|
155
169
|
vec
|
|
156
170
|
end
|
|
157
171
|
|
|
@@ -180,14 +194,17 @@ module Kapusta
|
|
|
180
194
|
closing_position = source_position
|
|
181
195
|
advance
|
|
182
196
|
|
|
183
|
-
raise
|
|
197
|
+
raise reader_error(:odd_forms_in_hash, opening_position) unless pending.empty?
|
|
184
198
|
|
|
185
199
|
hash = HashLit.new(entries)
|
|
186
200
|
hash.multiline_source = closing_position[0] != opening_position[0]
|
|
201
|
+
hash.line = opening_position[0]
|
|
202
|
+
hash.column = opening_position[1]
|
|
187
203
|
hash
|
|
188
204
|
end
|
|
189
205
|
|
|
190
206
|
def read_string
|
|
207
|
+
opening_position = source_position
|
|
191
208
|
advance
|
|
192
209
|
buffer = +''
|
|
193
210
|
until eof? || peek == '"'
|
|
@@ -211,7 +228,7 @@ module Kapusta
|
|
|
211
228
|
buffer << advance
|
|
212
229
|
end
|
|
213
230
|
end
|
|
214
|
-
raise
|
|
231
|
+
raise reader_error(:unterminated_string, opening_position) if eof?
|
|
215
232
|
|
|
216
233
|
advance
|
|
217
234
|
buffer
|
|
@@ -249,22 +266,23 @@ module Kapusta
|
|
|
249
266
|
end
|
|
250
267
|
|
|
251
268
|
def read_atom
|
|
269
|
+
position = source_position
|
|
252
270
|
start = @pos
|
|
253
271
|
advance until delim?(peek)
|
|
254
272
|
token = @src[start...@pos]
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
parse_atom(token)
|
|
273
|
+
parse_atom(token, position)
|
|
258
274
|
end
|
|
259
275
|
|
|
260
276
|
def unexpected_closing_delim(char)
|
|
261
|
-
|
|
262
|
-
Error.new("unexpected closing delimiter '#{char}' at line #{line}, column #{column}")
|
|
277
|
+
reader_error(:unexpected_closing_delimiter, source_position, char:)
|
|
263
278
|
end
|
|
264
279
|
|
|
265
280
|
def unclosed_opening_delim(char, position)
|
|
266
|
-
|
|
267
|
-
|
|
281
|
+
reader_error(:unclosed_delimiter, position, char:)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def reader_error(code, position, **args)
|
|
285
|
+
Error.new(Kapusta::Errors.format(code, **args), line: position[0], column: position[1])
|
|
268
286
|
end
|
|
269
287
|
|
|
270
288
|
def source_position
|
|
@@ -276,13 +294,15 @@ module Kapusta
|
|
|
276
294
|
[line, column]
|
|
277
295
|
end
|
|
278
296
|
|
|
279
|
-
def parse_atom(token)
|
|
297
|
+
def parse_atom(token, position)
|
|
280
298
|
return true if token == 'true'
|
|
281
299
|
return false if token == 'false'
|
|
282
300
|
return if token == 'nil'
|
|
283
301
|
return Integer(token, 10) if token.match?(/\A-?\d+\z/)
|
|
284
302
|
return Float(token) if token.match?(/\A-?\d+\.\d+\z/)
|
|
285
303
|
|
|
304
|
+
raise reader_error(:could_not_read_number, position, token:) if token.match?(/\A-?\d/)
|
|
305
|
+
|
|
286
306
|
if token.start_with?(':') && token.length > 1
|
|
287
307
|
Kapusta.kebab_to_snake(token[1..]).to_sym
|
|
288
308
|
elsif token.length > 1 && token.end_with?('#') && !token[0..-2].include?('#')
|
|
@@ -294,7 +314,7 @@ module Kapusta
|
|
|
294
314
|
|
|
295
315
|
def normalize_hash_pair(item, value)
|
|
296
316
|
if item.is_a?(Sym) && item.name == ':'
|
|
297
|
-
raise
|
|
317
|
+
raise reader_error(:bad_shorthand, source_position) unless value.is_a?(Sym)
|
|
298
318
|
|
|
299
319
|
key = Kapusta.kebab_to_snake(value.name).to_sym
|
|
300
320
|
[key, value]
|
data/lib/kapusta/version.rb
CHANGED