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.
@@ -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
@@ -72,7 +72,7 @@ module Kapusta
72
72
 
73
73
  def read_next_item
74
74
  skip_ws
75
- raise Error, 'unexpected eof' if eof?
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 Error, 'unexpected eof' if eof?
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 Error, 'odd number of forms in hash' unless pending.empty?
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 Error, 'unterminated string' if eof?
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
- raise Error, 'empty token' if token.empty?
256
-
257
- parse_atom(token)
273
+ parse_atom(token, position)
258
274
  end
259
275
 
260
276
  def unexpected_closing_delim(char)
261
- line, column = source_position
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
- line, column = position
267
- Error.new("unclosed opening delimiter '#{char}' at line #{line}, column #{column}")
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 Error, 'bad shorthand' unless value.is_a?(Sym)
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]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kapusta
4
- VERSION = '0.4.1'
4
+ VERSION = '0.7.0'
5
5
  end
data/lib/kapusta.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'kapusta/version'
4
4
  require_relative 'kapusta/error'
5
+ require_relative 'kapusta/errors'
5
6
  require_relative 'kapusta/support'
6
7
  require_relative 'kapusta/ast'
7
8
  require_relative 'kapusta/reader'