parsanol 1.3.96-arm-linux → 1.3.98-arm-linux

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8c1e5088e6ea4bc07a0b4a2bed1a554aee3d23b46c99ec3f3bcccd0849eb354
4
- data.tar.gz: 21d21fc2b86a2c43a1dd2faeac2b1c2746d104c011c4dff6bae7d0c86cf2a656
3
+ metadata.gz: c8602f267ec0d9c4b1a4a2717e725d21f9bc9dbd6dbfa46ec60d470367ce96f9
4
+ data.tar.gz: 18863ff69af04cdb26dd3ea6674b45dfa0878dc8779de82e167955b3eaa0120c
5
5
  SHA512:
6
- metadata.gz: b73b0a0446f8fc7aff65f4395ca5770bedda83359d5bf0e98914372cd54416aab6d2750615b89654f61b28b24399beac265ef08f8465d99466869e85162935f2
7
- data.tar.gz: 475cd0f458a8f625059bccab85da16d8d415e1b4fcdee0ea1ce6f9d8f551c27a98cdd1362b667101d1b4dc7a9bb9c9fc51f503697d4c61f163b7dacdf3518935
6
+ metadata.gz: 20df45e1c314d5a7480731e43bb7c3b658ef0823e4f8220be1b49550ade4b3622d390d2d3491078c64acc8585b7a857a6a296120c62acbe6e2ce5fbde40ca0d4
7
+ data.tar.gz: d2dca93905dd9509667b332df1ced30e19e01ed40622ead3ac46b75fb6c1a5bc8329b773ec5b8aae1dedc1e3504a57b55a1e06cc4fe9864ca2a84e1b9a72dbcb
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/3.2/parsanol_native.so
3
- size: 34469476 bytes
4
- sha256: 7aafcff9614529975999b15cff2a3e73ae9a85afcabe8f9e58c13038cab73a23
3
+ size: 34469500 bytes
4
+ sha256: 157a672c14622b88628108cca7fee4f4b532109ee6ba500ba84c350855c3c0dc
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/3.3/parsanol_native.so
3
- size: 34654656 bytes
4
- sha256: fa31365628d79247b120a2c0a354730afefdab03fe0d4562571887317a919dcd
3
+ size: 34654620 bytes
4
+ sha256: dfc8a952607d9be23fb7e8ca226b670c72229e730f784c5c247481ef1e1de634
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/3.4/parsanol_native.so
3
- size: 34648844 bytes
4
- sha256: d5fce27c9af76ff94d7a28b4ac70ac36cb39ede3a5f1c3ece385a07fb238e233
3
+ size: 34648804 bytes
4
+ sha256: 47d0c114a890d67aff8a5321fd66082e831974b942072e843095cca4b95f34ab
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: data/lib/parsanol/4.0/parsanol_native.so
3
- size: 34658520 bytes
4
- sha256: 0de52572076fa045d63465d34b4aaa245ff76291e742820ae58769f71fa14866
3
+ size: 34658480 bytes
4
+ sha256: 0d5b7266d2515f92a790da66a7aed125dae7e157b064f88c633026014138fc95
Binary file
@@ -178,8 +178,9 @@ module Parsanol
178
178
  when "capture"
179
179
  captures = Bindings.collect_captures(shape)
180
180
  mismatched = hash["expect"].reject do |key, value|
181
- actual = captures[key.to_sym]&.first
182
- !actual.nil? && Bindings.canonical(actual) == Bindings.canonical(value)
181
+ values = captures[key.to_sym]
182
+ values && !values.empty? &&
183
+ Bindings.canonical(values.first) == Bindings.canonical(value)
183
184
  end
184
185
  next if mismatched.empty?
185
186
 
@@ -64,7 +64,9 @@ module Parsanol
64
64
  count = group.map { |binding| captures[binding["capture"].to_sym].length }.max || 0
65
65
  return if count.zero?
66
66
 
67
- list = (out[prefix] ||= [])
67
+ # Field names are symbols everywhere else (scalar bindings, element
68
+ # fields); the array prefix must match or `example` lookups miss it.
69
+ list = (out[prefix.to_sym] ||= [])
68
70
  count.times do |index|
69
71
  element = {}
70
72
  group.each do |binding|
@@ -337,6 +337,12 @@ module Parsanol
337
337
  find_left_cycle_from(name, [], {})
338
338
  end
339
339
 
340
+ # In-file test failures without raising: the LSP surfaces them as
341
+ # diagnostics instead of failing a build.
342
+ def test_failures
343
+ run_tests
344
+ end
345
+
340
346
  private
341
347
 
342
348
  # In-file grammar tests run at compile time: the build fails when an
@@ -372,8 +378,9 @@ module Parsanol
372
378
  when :capture
373
379
  captures = Bindings.collect_captures(shape)
374
380
  mismatched = test.expect.reject do |key, value|
375
- actual = captures[key.to_sym]&.first
376
- !actual.nil? && Bindings.canonical(actual) == Bindings.canonical(value)
381
+ values = captures[key.to_sym]
382
+ values && !values.empty? &&
383
+ Bindings.canonical(values.first) == Bindings.canonical(value)
377
384
  end
378
385
  next if mismatched.empty?
379
386
 
@@ -23,7 +23,7 @@ module Parsanol
23
23
  | (?<arrow>->)
24
24
  | (?<num>\d+)
25
25
  | (?<ident>[A-Za-z_][A-Za-z0-9_]*)
26
- | (?<punct>[=\/()\[\]{}*!&:,.<])
26
+ | (?<punct>[=\/()\[\]{}*!&:,.<-])
27
27
  /x
28
28
 
29
29
  def initialize(text)
@@ -1,26 +1,63 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "rbconfig"
4
+ require "strscan"
5
+ require "uri"
5
6
 
6
7
  module Parsanol
7
8
  module PARG
8
- # F11: minimal PARG LSP over stdio (JSON-RPC 2.0, no framework).
9
+ # PARG language server over stdio (JSON-RPC 2.0, no framework).
9
10
  #
10
11
  # initialize / shutdown / exit
11
- # textDocument/didOpen|didChange -> publishDiagnostics
12
- # (parse errors, lint errors, failing inline tests)
13
- # textDocument/hover -> the rule's ## doc comment
12
+ # textDocument/didOpen|didChange|didClose -> publishDiagnostics
13
+ # (parse errors, lint errors, failing inline tests; imports merged)
14
+ # textDocument/hover -> rule docs (Imports-aware)
15
+ # textDocument/definition -> rule definition / imported file
16
+ # textDocument/references -> rule occurrences
17
+ # textDocument/completion -> keywords, rule names, snippets
18
+ # textDocument/semanticTokens/full
19
+ # textDocument/codeAction -> longest-first reorder
14
20
  #
15
- # Diagnostics and hover run entirely on the compiler API — one
16
- # language implementation, every editor.
21
+ # Everything runs on the compiler API and the parsed Document model — one
22
+ # language implementation, every editor. Imported grammars are resolved
23
+ # from the document's directory and PG_IMPORT_DIR (path-separator list).
17
24
  class Lsp
18
25
  CONTENT_LENGTH = "Content-Length: "
19
26
 
27
+ SEMANTIC_TYPES = %w[keyword rule capture string number comment].freeze
28
+ SEMANTIC_MODIFIERS = %w[declaration].freeze
29
+ DECLARATION_BIT = 1
30
+
31
+ KEYWORDS = (%w[version test accept reject example use] + Parser::KEYWORDS).uniq.freeze
32
+
33
+ COMPLETION_KEYWORDS = KEYWORDS.map do |keyword|
34
+ { "label" => keyword, "kind" => 14 }
35
+ end.freeze
36
+
37
+ SNIPPETS = [
38
+ ["%i\"...\"", "%i\"${1:text}\"", "case-insensitive literal"],
39
+ ["%x41", "%x41", "one code point"],
40
+ ["%x41-5A", "%x41-5A", "code point range"],
41
+ ["%x0D.0A", "%x0D.0A", "code point sequence"],
42
+ ["[ ... ]", "[ ${1:content} ]", "optional"],
43
+ ["*( ... )", "*( ${1:content} )", "zero or more"],
44
+ ["1*( ... )", "1*( ${1:content} )", "one or more"],
45
+ ["!x", "!${1:x}", "negative lookahead"],
46
+ ["&x", "&${1:x}", "positive lookahead"],
47
+ ["!<x", "!< ${1:x}", "negative behind-guard"],
48
+ ["&<x", "&< ${1:x}", "positive behind-guard"],
49
+ ["as name", "as ${1:name}", "capture"],
50
+ ].map do |label, insert, detail|
51
+ { "label" => label, "kind" => 15, "detail" => detail,
52
+ "insertText" => insert, "insertTextFormat" => 2 }
53
+ end.freeze
54
+
20
55
  def initialize(input = $stdin, output = $stdout)
21
56
  @input = input
22
57
  @output = output
23
58
  @documents = {}
59
+ @parsed = {}
60
+ @import_paths = {}
24
61
  end
25
62
 
26
63
  def run
@@ -45,6 +82,19 @@ module Parsanol
45
82
  "capabilities" => {
46
83
  "textDocumentSync" => 1,
47
84
  "hoverProvider" => true,
85
+ "definitionProvider" => true,
86
+ "referencesProvider" => true,
87
+ "codeActionProvider" => true,
88
+ "completionProvider" => {
89
+ "triggerCharacters" => [".", "%", "\""],
90
+ },
91
+ "semanticTokensProvider" => {
92
+ "legend" => {
93
+ "tokenTypes" => SEMANTIC_TYPES,
94
+ "tokenModifiers" => SEMANTIC_MODIFIERS,
95
+ },
96
+ "full" => true,
97
+ },
48
98
  },
49
99
  "serverInfo" => { "name" => "parsanol-parg-lsp" },
50
100
  })
@@ -56,17 +106,32 @@ module Parsanol
56
106
  doc = message["params"]["textDocument"]
57
107
  @documents[doc["uri"]] =
58
108
  doc["text"] || message.dig("params", "contentChanges", -1, "text")
109
+ @parsed.delete(doc["uri"])
59
110
  publish_diagnostics(doc["uri"])
111
+ when "textDocument/didClose"
112
+ uri = message.dig("params", "textDocument", "uri")
113
+ @documents.delete(uri)
114
+ @parsed.delete(uri)
60
115
  when "textDocument/codeAction"
61
116
  respond(message, code_action(message["params"]))
62
117
  when "textDocument/hover"
63
- respond(message, hover(message.dig("params", "textDocument", "uri"),
64
- message.dig("params", "position")))
118
+ respond(message, hover(*position_params(message["params"])))
119
+ when "textDocument/definition"
120
+ respond(message, definition(*position_params(message["params"])))
121
+ when "textDocument/references"
122
+ params = message["params"]
123
+ respond(message, references(params.dig("textDocument", "uri"),
124
+ params["position"],
125
+ params.dig("context", "includeDeclaration")))
126
+ when "textDocument/completion"
127
+ respond(message, completion(message.dig("params", "textDocument", "uri")))
128
+ when "textDocument/semanticTokens/full"
129
+ respond(message, semantic_tokens(message.dig("params", "textDocument", "uri")))
65
130
  end
66
131
  end
67
132
 
68
133
  def respond(message, result)
69
- send_message("id" => message["id"], "result" => result)
134
+ send_message("jsonrpc" => "2.0", "id" => message["id"], "result" => result)
70
135
  end
71
136
 
72
137
  def notify(method, params)
@@ -79,12 +144,379 @@ module Parsanol
79
144
  @output.flush
80
145
  end
81
146
 
147
+ # ---- documents and imports -------------------------------------------
148
+
149
+ def position_params(params)
150
+ [params.dig("textDocument", "uri"), params["position"]]
151
+ end
152
+
153
+ def document_source(uri)
154
+ return @documents[uri] if @documents.key?(uri)
155
+
156
+ path = @import_paths[uri] || file_path(uri)
157
+ return nil unless path && File.file?(path)
158
+
159
+ File.read(path)
160
+ end
161
+
162
+ def parsed(uri)
163
+ source = @documents[uri]
164
+ return nil unless source
165
+
166
+ cached = @parsed[uri]
167
+ return cached[1] if cached && cached[0] == source
168
+
169
+ document = begin
170
+ Parser.new(source).parse
171
+ rescue ParseError
172
+ nil
173
+ end
174
+ @parsed[uri] = [source, document]
175
+ document
176
+ end
177
+
178
+ def file_path(uri)
179
+ text = uri.to_s
180
+ return nil unless text.start_with?("file://")
181
+
182
+ URI::DEFAULT_PARSER.unescape(text.delete_prefix("file://"))
183
+ end
184
+
185
+ def import_dirs(uri)
186
+ dirs = []
187
+ path = file_path(uri)
188
+ dirs << File.dirname(path) if path
189
+ ENV.fetch("PG_IMPORT_DIR", "").split(File::PATH_SEPARATOR).each do |dir|
190
+ dirs << dir unless dir.to_s.empty?
191
+ end
192
+ dirs.uniq
193
+ end
194
+
195
+ def import_uri(uri, name)
196
+ import_dirs(uri).each do |dir|
197
+ path = File.join(dir, "#{name}.parg")
198
+ next unless File.file?(path)
199
+
200
+ expanded = File.expand_path(path)
201
+ target = "file://#{URI::DEFAULT_PARSER.escape(expanded)}"
202
+ @import_paths[target] = expanded
203
+ return target
204
+ end
205
+ nil
206
+ end
207
+
208
+ # ---- rule model ------------------------------------------------------
209
+
210
+ Definition = Struct.new(:line, :character, :name_length, :docs)
211
+
212
+ # Rule definitions with their preceding ## doc comments. Works on a
213
+ # source that does not parse: hover/definition stay useful mid-edit.
214
+ def rule_definitions(source)
215
+ defs = {}
216
+ lines = source.to_s.lines
217
+ lines.each_with_index do |line, index|
218
+ match = line.match(/\A(\s*)(?:atomic\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/)
219
+ next unless match
220
+
221
+ name = match[2]
222
+ docs = []
223
+ cursor = index - 1
224
+ while cursor.negative? == false && lines[cursor].to_s.strip.start_with?("##")
225
+ docs.unshift(lines[cursor].strip.sub(/\A##\s?/, ""))
226
+ cursor -= 1
227
+ end
228
+ defs[name] = Definition.new(index, match.begin(2), name.length, docs.join("\n"))
229
+ end
230
+ defs
231
+ end
232
+
233
+ def rule_names(uri)
234
+ document = parsed(uri)
235
+ return document.rules.keys if document
236
+
237
+ rule_definitions(document_source(uri)).keys
238
+ end
239
+
240
+ # Resolve a (possibly dotted) rule reference to its defining file.
241
+ def resolve(uri, word)
242
+ segments = word.to_s.split(".").reject(&:empty?)
243
+ return nil if segments.empty?
244
+
245
+ resolve_segments(uri, segments)
246
+ end
247
+
248
+ def resolve_segments(uri, segments)
249
+ source = document_source(uri)
250
+ return nil unless source
251
+
252
+ if segments.size == 1
253
+ name = segments.first
254
+ defs = rule_definitions(source)
255
+ definition = defs[name]
256
+ return nil unless definition
257
+
258
+ return { uri: uri, name: name, definition: definition }
259
+ end
260
+
261
+ document = if @documents.key?(uri)
262
+ parsed(uri)
263
+ else
264
+ begin
265
+ Parser.new(source).parse
266
+ rescue ParseError
267
+ nil
268
+ end
269
+ end
270
+ return nil unless document&.uses&.include?(segments.first)
271
+
272
+ target = import_uri(uri, segments.first)
273
+ return nil unless target
274
+
275
+ resolve_segments(target, segments[1..])
276
+ end
277
+
278
+ def word_at(source, position)
279
+ return nil unless source && position
280
+
281
+ line = source.lines[position["line"].to_i].to_s
282
+ character = position["character"].to_i
283
+ left = line[0...character].to_s
284
+ right = line[character..].to_s
285
+ prefix = left[/[A-Za-z0-9_.]*\z/] || ""
286
+ suffix = right[/\A[A-Za-z0-9_.]*/] || ""
287
+ word = (prefix + suffix).sub(/\A\.+/, "").sub(/\.+\z/, "")
288
+ return nil if word.empty?
289
+
290
+ word
291
+ end
292
+
293
+ def import_use_line?(source, position)
294
+ line = source.lines[position["line"].to_i].to_s
295
+ line.match?(/\A\s*use\s+/)
296
+ end
297
+
298
+ def location(uri, line, character, length)
299
+ {
300
+ "uri" => uri,
301
+ "range" => {
302
+ "start" => { "line" => line, "character" => character },
303
+ "end" => { "line" => line, "character" => character + length },
304
+ },
305
+ }
306
+ end
307
+
308
+ # ---- hover / definition / references ---------------------------------
309
+
310
+ def hover(uri, position)
311
+ source = document_source(uri)
312
+ return nil unless source && position
313
+
314
+ word = word_at(source, position)
315
+ return nil unless word
316
+
317
+ if import_use_line?(source, position)
318
+ target = import_uri(uri, word)
319
+ return nil unless target
320
+
321
+ return { "contents" => { "kind" => "markdown",
322
+ "value" => "Import `#{word}`\n\n`#{File.basename(target)}`" } }
323
+ end
324
+
325
+ resolved = resolve(uri, word)
326
+ return nil unless resolved
327
+
328
+ definition = resolved[:definition]
329
+ return nil unless definition
330
+
331
+ value = if definition.docs.empty?
332
+ "`#{resolved[:name]}` — rule"
333
+ else
334
+ "`#{resolved[:name]}`\n\n#{definition.docs}"
335
+ end
336
+ { "contents" => { "kind" => "markdown", "value" => value } }
337
+ end
338
+
339
+ def definition(uri, position)
340
+ source = document_source(uri)
341
+ return nil unless source && position
342
+
343
+ word = word_at(source, position)
344
+ return nil unless word
345
+
346
+ if import_use_line?(source, position)
347
+ target = import_uri(uri, word)
348
+ return target ? location(target, 0, 0, 0) : nil
349
+ end
350
+
351
+ resolved = resolve(uri, word)
352
+ return nil unless resolved
353
+
354
+ definition = resolved[:definition]
355
+ location(resolved[:uri], definition.line, definition.character,
356
+ definition.name_length)
357
+ end
358
+
359
+ def references(uri, position, include_declaration)
360
+ source = document_source(uri)
361
+ return nil unless source && position
362
+
363
+ word = word_at(source, position)
364
+ resolved = word && resolve(uri, word)
365
+ return nil unless resolved
366
+
367
+ name = resolved[:name]
368
+ uris = [resolved[:uri], uri].uniq
369
+ results = []
370
+ uris.each do |target_uri|
371
+ target_source = document_source(target_uri)
372
+ next unless target_source
373
+
374
+ qualified = target_uri == uri && resolved[:uri] != uri && word.include?(".")
375
+ needle = qualified ? word : name
376
+ declaration = rule_definitions(target_source)[name]
377
+ target_source.lines.each_with_index do |line, index|
378
+ identifier_occurrences(line, needle).each do |character, length|
379
+ declaration_here = declaration && declaration.line == index &&
380
+ character == declaration.character && needle == name
381
+ results << location(target_uri, index, character, length) if
382
+ include_declaration || !declaration_here
383
+ end
384
+ end
385
+ end
386
+ results
387
+ end
388
+
389
+ # Identifier tokens outside strings and comments: a rule name inside a
390
+ # literal or a doc comment is not a reference.
391
+ def identifier_occurrences(line, name)
392
+ found = []
393
+ scanner = StringScanner.new(line)
394
+ until scanner.eos?
395
+ start = scanner.pos
396
+ start_char = line.byteslice(0, start).length
397
+ if scanner.scan(/#.*/)
398
+ break
399
+ elsif scanner.scan(/%(?:i|s)?"(?:\\.|[^"\\])*"/) ||
400
+ scanner.scan(/"(?:\\.|[^"\\])*"/) ||
401
+ scanner.scan(/%x[0-9A-Fa-f]{2}(?:[.-][0-9A-Fa-f]{2})*|[0-9]+/) ||
402
+ scanner.scan(/\s+/)
403
+ next
404
+ elsif (text = scanner.scan(/[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*/))
405
+ found << [start_char, text.length] if text == name
406
+ else
407
+ scanner.getch
408
+ end
409
+ end
410
+ found
411
+ end
412
+
413
+ # ---- completion ------------------------------------------------------
414
+
415
+ def completion(uri)
416
+ source = document_source(uri)
417
+ return nil unless source
418
+
419
+ items = COMPLETION_KEYWORDS.dup + SNIPPETS.dup
420
+ rule_names(uri).each do |name|
421
+ items << { "label" => name, "kind" => 18, "detail" => "rule" }
422
+ end
423
+ document = parsed(uri)
424
+ (document&.uses || []).each do |import|
425
+ target = import_uri(uri, import)
426
+ next unless target
427
+
428
+ rule_definitions(document_source(target)).each_key do |name|
429
+ items << { "label" => "#{import}.#{name}", "kind" => 18,
430
+ "detail" => "imported rule" }
431
+ end
432
+ end
433
+ { "isIncomplete" => false, "items" => items }
434
+ end
435
+
436
+ # ---- semantic tokens -------------------------------------------------
437
+
438
+ def semantic_tokens(uri)
439
+ source = document_source(uri)
440
+ return nil unless source
441
+
442
+ names = rule_names(uri)
443
+ tokens = []
444
+ source.lines.each_with_index do |line, line_no|
445
+ scan_line(line, line_no, names, tokens)
446
+ end
447
+ { "data" => encode_tokens(tokens) }
448
+ end
449
+
450
+ def scan_line(line, line_no, names, tokens)
451
+ scanner = StringScanner.new(line)
452
+ previous_word = nil
453
+ until scanner.eos?
454
+ start = scanner.pos
455
+ start_char = line.byteslice(0, start).length
456
+
457
+ if (text = scanner.scan(/#.*/))
458
+ tokens << [line_no, start_char, text.length, "comment", 0]
459
+ break
460
+ elsif (text = scanner.scan(/%(?:i|s)?"(?:\\.|[^"\\])*"|"(?:\\.|[^"\\])*"/))
461
+ tokens << [line_no, start_char, text.length, "string", 0]
462
+ previous_word = nil
463
+ elsif (text = scanner.scan(/%x[0-9A-Fa-f]{2}(?:[.-][0-9A-Fa-f]{2})*|[0-9]+/))
464
+ tokens << [line_no, start_char, text.length, "number", 0]
465
+ previous_word = nil
466
+ elsif (text = scanner.scan(/[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*/))
467
+ type, modifiers = classify_word(line, start_char, text, names, previous_word)
468
+ tokens << [line_no, start_char, text.length, type, modifiers] if type
469
+ previous_word = text
470
+ elsif (text = scanner.scan(/\s+/))
471
+ _ = text
472
+ else
473
+ scanner.getch
474
+ previous_word = nil
475
+ end
476
+ end
477
+ end
478
+
479
+ def classify_word(line, start_char, text, names, previous_word)
480
+ base = text.split(".").last
481
+ if KEYWORDS.include?(text)
482
+ ["keyword", 0]
483
+ elsif previous_word == "as"
484
+ ["capture", 0]
485
+ elsif names.include?(base) || text.include?(".")
486
+ modifier = declaration_here?(line, start_char, text) ? DECLARATION_BIT : 0
487
+ ["rule", modifier]
488
+ elsif %w[entry bindings test].include?(previous_word)
489
+ ["rule", DECLARATION_BIT]
490
+ end
491
+ end
492
+
493
+ def declaration_here?(line, start_char, text)
494
+ before = line[0...start_char].to_s
495
+ after = line[(start_char + text.length)..].to_s
496
+ before.match?(/\A\s*(?:atomic\s+)?\z/) && after.match?(/\A\s*=/)
497
+ end
498
+
499
+ def encode_tokens(tokens)
500
+ data = []
501
+ last_line = 0
502
+ last_char = 0
503
+ tokens.sort_by { |token| [token[0], token[1]] }.each do |line, char, length, type, modifiers|
504
+ delta_line = line - last_line
505
+ delta_char = delta_line.zero? ? char - last_char : char
506
+ data.push(delta_line, delta_char, length, SEMANTIC_TYPES.index(type), modifiers)
507
+ last_line = line
508
+ last_char = char
509
+ end
510
+ data
511
+ end
512
+
513
+ # ---- diagnostics -----------------------------------------------------
514
+
82
515
  def publish_diagnostics(uri)
83
516
  source = @documents[uri]
84
517
  diagnostics = []
85
- document = nil
86
- begin
87
- document = Parser.new(source).parse
518
+ document = begin
519
+ Parser.new(source).parse
88
520
  rescue ParseError => e
89
521
  line = 0
90
522
  # The self-hosting artifact yields a precise offset (F7 wire);
@@ -97,16 +529,23 @@ module Parsanol
97
529
  end
98
530
  end
99
531
  diagnostics << diagnostic(line, e.message)
532
+ nil
100
533
  end
101
534
  if document
535
+ dirs = import_dirs(uri)
102
536
  begin
103
- tables_dir = ENV.fetch("PG_TABLES_DIR", nil)
537
+ Imports.merge!(document, dirs) if document.uses.any?
538
+ rescue PARG::Error, ParseError => e
539
+ diagnostics << diagnostic(use_line(source, document), e.message)
540
+ end
541
+ begin
542
+ tables_dir = ENV.fetch("PG_TABLES_DIR", nil) || dirs.first
104
543
  compiler = Compiler.new(document, tables_dir)
105
544
  document.rules.each_key { |rule| compiler.atom_for(rule) }
106
545
  Lints.errors(document, compiler).each do |error|
107
546
  diagnostics << diagnostic(rule_line(source, error[/\Arule (\w+):/, 1]), error)
108
547
  end
109
- compiler.run_tests.each do |failure|
548
+ compiler.test_failures.each do |failure|
110
549
  diagnostics << diagnostic(0, failure)
111
550
  end
112
551
  rescue ParseError, PARG::Error => e
@@ -117,11 +556,18 @@ module Parsanol
117
556
  "uri" => uri, "diagnostics" => diagnostics)
118
557
  end
119
558
 
559
+ def use_line(source, document)
560
+ name = document.uses.first
561
+ return 0 unless name
562
+
563
+ source.lines.index { |line| line.match?(/^\s*use\s+#{Regexp.escape(name)}\b/) } || 0
564
+ end
565
+
120
566
  # Rule-granular position: the line the named rule is defined on.
121
567
  def rule_line(source, rule_name)
122
568
  return 0 unless rule_name
123
569
 
124
- source.lines.index { |l| l.match?(/^\s*#{rule_name}\s*=/) } || 0
570
+ source.lines.index { |line| line.match?(/^\s*#{rule_name}\s*=/) } || 0
125
571
  end
126
572
 
127
573
  def diagnostic(line, message)
@@ -134,6 +580,8 @@ module Parsanol
134
580
  }
135
581
  end
136
582
 
583
+ # ---- code action -----------------------------------------------------
584
+
137
585
  # Reorder code action: only when EVERY top-level alternative of the
138
586
  # rule is a quoted literal is the reorder mechanically safe.
139
587
  def code_action(params)
@@ -176,33 +624,6 @@ module Parsanol
176
624
  { "start" => { "line" => line_no, "character" => 0 },
177
625
  "end" => { "line" => line_no, "character" => 1000 } }
178
626
  end
179
-
180
- def hover(uri, position)
181
- source = @documents[uri]
182
- return nil unless source && position
183
-
184
- line_no = position.dig("position", "line") || position["line"]
185
- text = source.lines[line_no.to_i].to_s
186
- rule = text[/^\s*([a-z_]+)\s*=/, 1]
187
- return nil unless rule
188
-
189
- lines = source.lines
190
- rule_line = lines.index { |l| l.match?(/^\s*#{rule}\s*=/) }
191
- return nil unless rule_line
192
-
193
- doc_lines = []
194
- cursor = rule_line - 1
195
- while cursor >= 0 && lines[cursor].strip.start_with?("##")
196
- doc_lines.unshift(lines[cursor])
197
- cursor -= 1
198
- end
199
- return nil if doc_lines.empty?
200
-
201
- doc = doc_lines.map(&:strip).join("\n")
202
-
203
- { "contents" => { "kind" => "markdown",
204
- "value" => doc.gsub(/^##\s?/, "").strip } }
205
- end
206
627
  end
207
628
  end
208
629
  end
@@ -91,6 +91,8 @@ module Parsanol
91
91
  punct(":")
92
92
  expect_pairs[key.to_sym] = parse_test_value
93
93
  skip_newlines
94
+ advance if peek&.type == :punct && peek.value == ","
95
+ skip_newlines
94
96
  end
95
97
  punct("}")
96
98
  end
@@ -101,18 +103,84 @@ module Parsanol
101
103
  skip_newlines
102
104
  end
103
105
 
106
+ # Expectation values: strings, numbers, true/false/null, arrays, and
107
+ # nested objects. Nested structure is what pins array bindings,
108
+ # absent-optional nil, and flatten/merge results in the corpus;
109
+ # before this the test grammar only accepted scalars.
104
110
  def parse_test_value
105
- token = advance
111
+ token = peek
106
112
  case token&.type
107
- when :str then unquote(token.value)
108
- when :num then token.value.to_i
109
- when :ident
110
- raise ParseError, "expected true or false" unless %w[true false].include?(token.value)
113
+ when :str then unquote(advance.value)
114
+ when :num then parse_test_number
115
+ when :punct
116
+ case token.value
117
+ when "[" then parse_test_array
118
+ when "{" then parse_test_object
119
+ when "-"
120
+ advance
121
+ unless peek&.type == :num
122
+ raise ParseError, "expected a number after '-' in a test value"
123
+ end
111
124
 
112
- token.value == "true"
125
+ -parse_test_number
126
+ else
127
+ raise ParseError,
128
+ "expected a test value (string, number, true, false, null, array, object)"
129
+ end
130
+ when :ident
131
+ advance
132
+ case token.value
133
+ when "true" then true
134
+ when "false" then false
135
+ when "null" then nil
136
+ else
137
+ raise ParseError,
138
+ "expected true, false, or null, got #{token.value.inspect}"
139
+ end
113
140
  else
114
- raise ParseError, "expected a string, number, true or false"
141
+ raise ParseError,
142
+ "expected a test value (string, number, true, false, null, array, object)"
143
+ end
144
+ end
145
+
146
+ # The lexer emits integers only; a decimal is num "." num.
147
+ def parse_test_number
148
+ text = advance.value
149
+ if peek&.type == :punct && peek.value == "." && @tokens[@pos + 1]&.type == :num
150
+ advance
151
+ text = "#{text}.#{advance.value}"
115
152
  end
153
+ text.include?(".") ? Float(text) : Integer(text, 10)
154
+ end
155
+
156
+ def parse_test_array
157
+ punct("[")
158
+ values = []
159
+ skip_newlines
160
+ until peek&.type == :punct && peek.value == "]"
161
+ values << parse_test_value
162
+ skip_newlines
163
+ advance if peek&.type == :punct && peek.value == ","
164
+ skip_newlines
165
+ end
166
+ punct("]")
167
+ values
168
+ end
169
+
170
+ def parse_test_object
171
+ punct("{")
172
+ value = {}
173
+ skip_newlines
174
+ until peek&.type == :punct && peek.value == "}"
175
+ key = ident.value
176
+ punct(":")
177
+ value[key.to_sym] = parse_test_value
178
+ skip_newlines
179
+ advance if peek&.type == :punct && peek.value == ","
180
+ skip_newlines
181
+ end
182
+ punct("}")
183
+ value
116
184
  end
117
185
 
118
186
  def eof? = @pos >= @tokens.length
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Parsanol
4
6
  module PARG
5
7
  # F10 phase 2 (gate slice): every .parg source must parse under the parg
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = "1.3.96"
4
+ VERSION = "1.3.98"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parsanol
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.96
4
+ version: 1.3.98
5
5
  platform: arm-linux
6
6
  authors:
7
7
  - Ribose Inc.