parsanol 1.3.95-arm-linux → 1.3.97-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: 6eaa2ffb569f87786261cf549810027f1590d095886b25b8b1da9ff99c1bbace
4
- data.tar.gz: 88d4a183b4133462d649f786cce6a65172df02618bd5a7896caf36a23e6faefa
3
+ metadata.gz: ba796c36445f5830ba53adb2d9d6c7423144702135b2fe6dbe999cc3b48ec02d
4
+ data.tar.gz: 1cbe38d5b55134e7a5e1e5b1fa0205fe486704e63b5d1b6513c4a28336abe122
5
5
  SHA512:
6
- metadata.gz: 21956f4d6b9533fbabb717b97259b13e7168e7e68faf9e14ec636e67b6e003c1b80f54f557c2517f2a822f32752ed845a4235179e16ee103ec163440034b11f8
7
- data.tar.gz: 663deb78c2b945cde8890b0e7ca35c34fe413fa0a719ecb4f9fde3ec48a9a2efeedc357e3e264d486a34af0ce240b7b06f5702b59ba4eb491f24d76813d6e071
6
+ metadata.gz: 9c5e89505e328023fd10183fb5fae55147b07a549c5e263507e44e86d4521f58bb0b853e28cb83a000f1e07de3e637c2f074402baffd0b7c8236839514c4b55d
7
+ data.tar.gz: 6080805a38b9c604012a4bcc44954816eb16e59b7eefeb1fcbd269bbdff9a2d04ec69c2a4f266761ae45a75df5fbd1f7fa6fd8b6b8cc735d52a7e58524938a49
@@ -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: 34333284 bytes
4
- sha256: 46c18a5ba0af8926c012eb98c6417dfca64931a837629b317fb7c3cc430b9085
3
+ size: 34469476 bytes
4
+ sha256: 7aafcff9614529975999b15cff2a3e73ae9a85afcabe8f9e58c13038cab73a23
@@ -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: 34518452 bytes
4
- sha256: 0752c79e340c4c722b106443ff1faf6ff6d6d7c5800570906d1c548585e06a58
3
+ size: 34654656 bytes
4
+ sha256: fa31365628d79247b120a2c0a354730afefdab03fe0d4562571887317a919dcd
@@ -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: 34512620 bytes
4
- sha256: 9b0744875d02d5c1957b82cdf9249129ce62214696606b84886609243342e23f
3
+ size: 34648844 bytes
4
+ sha256: d5fce27c9af76ff94d7a28b4ac70ac36cb39ede3a5f1c3ece385a07fb238e233
@@ -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: 34522276 bytes
4
- sha256: 834bad07a21ef4ccaeb47fbfaf85c567866142fb141f6d93205d5d9958e0c2f2
3
+ size: 34658520 bytes
4
+ sha256: 0de52572076fa045d63465d34b4aaa245ff76291e742820ae58769f71fa14866
@@ -72,6 +72,10 @@ module Parsanol
72
72
  # and the NEXT successful Named capture attaches (and drains)
73
73
  # them under `comments:`.
74
74
  @pending_trivia = []
75
+
76
+ # Recovery journal (TODO.sota/1): non-nil only in recovery
77
+ # re-parses; Named captures append to the shared array.
78
+ @recovery_journal = Parsanol::Recovery.journal if Parsanol::Recovery.journaling?
75
79
  @evict_threshold = 200
76
80
  @evict_counter = 0
77
81
  @evict_interval = 100
@@ -327,6 +331,10 @@ module Parsanol
327
331
  # @return [Parsanol::Scope] capture variable bindings
328
332
  attr_reader :captures
329
333
 
334
+ # Recovery journal (TODO.sota/1): a shared array when this
335
+ # context was created inside a recovery re-parse; nil otherwise.
336
+ attr_accessor :recovery_journal
337
+
330
338
  # @return [Parsanol::Pools::ArrayPool] array object pool
331
339
  attr_reader :array_pool
332
340
 
@@ -42,6 +42,8 @@ module Parsanol
42
42
  comments = context.take_pending_trivia
43
43
  result = wrap_result(value)
44
44
  result = attach_comments(result, comments, source) unless comments.empty?
45
+ # Recovery journal (TODO.sota/1): nil on every normal parse.
46
+ context.recovery_journal << [@name, result] if context.recovery_journal
45
47
  ok(result)
46
48
  end
47
49
 
Binary file
@@ -382,6 +382,20 @@ module Parsanol
382
382
  :unavailable
383
383
  end
384
384
  end
385
+ name_cache_version if @native_state == :loaded
386
+ @native_state
387
+ end
388
+
389
+ private
390
+
391
+ # Compiled VM programs are cached on disk across processes; naming
392
+ # this gem release in the cache key means a new release never runs
393
+ # a program an older one compiled. Extensions built before the
394
+ # engine grew the hook simply skip it.
395
+ def name_cache_version
396
+ return unless respond_to?(:_set_cache_version)
397
+
398
+ _set_cache_version("parsanol-ruby #{Parsanol::VERSION}")
385
399
  end
386
400
  end
387
401
  end
@@ -197,6 +197,53 @@ module Parsanol
197
197
  end
198
198
  end
199
199
 
200
+ # Best-effort recovery (TODO.sota/1, R1): on success returns
201
+ # {"ok": true, "shape": tree}; on ParseFailed returns
202
+ # {"ok": false, "error": {"offset", "message"}, "captures":
203
+ # {name => [values...]}} — the captures observed before the
204
+ # failure, from a journaling re-parse on the Ruby engine (tree
205
+ # parity with the native engine is enforced by the differential
206
+ # specs). The journal is best-effort: it may include captures
207
+ # from branches that later failed.
208
+ def parse_recovering(entry_name, input, mode: :native)
209
+ shape = parse(entry_name, input, mode: mode)
210
+ { "ok" => true, "shape" => shape }
211
+ rescue Parsanol::ParseFailed => e
212
+ { "ok" => false,
213
+ "error" => recovery_error(e),
214
+ "captures" => recovery_journal(entry_name, input, mode) }
215
+ end
216
+
217
+ def recovery_error(err)
218
+ pos = err.parse_failure_cause&.position
219
+ { "offset" => pos, "message" => err.message }
220
+ end
221
+
222
+ # Journaling re-parse — always on the Ruby engine (journaling
223
+ # rides the interpreter's diagnostic reparse; the native engine
224
+ # has no journal). Tree parity with the native engine is enforced
225
+ # by the differential specs, so the observed captures agree.
226
+ def recovery_journal(entry_name, input, _mode)
227
+ Parsanol::Recovery.start_journal
228
+ begin
229
+ parse(entry_name, input, mode: :ruby)
230
+ rescue Parsanol::ParseFailed
231
+ # expected: the journaling pass fails at the same point
232
+ end
233
+ Parsanol::Recovery.stop_journal.each_with_object({}) do |(name, value), out|
234
+ # Both the initial attempt and the diagnostic re-parse journal
235
+ # into the shared thread-local; identical observations dedupe.
236
+ wire = value.is_a?(Slice) ? slice_wire(value) : value
237
+ (out[name.to_s] ||= []) << wire
238
+ out[name.to_s].uniq!
239
+ end
240
+ end
241
+
242
+ def slice_wire(slice)
243
+ { "value" => slice.to_s, "offset" => slice.offset,
244
+ "length" => slice.content.length }
245
+ end
246
+
200
247
  def test_to_hash(test)
201
248
  {
202
249
  "entry" => test.entry,
@@ -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
@@ -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
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parsanol
4
+ # Recovery journaling (TODO.sota/1): when enabled for the current
5
+ # thread, every Named capture appends to a shared journal for the
6
+ # duration of the recovery re-parse. Success paths never enable it —
7
+ # normal parses see only a nil-context check.
8
+ module Recovery
9
+ module_function
10
+
11
+ def start_journal
12
+ Thread.current[:parsanol_recovery_journal] = []
13
+ Thread.current[:parsanol_recovery_journaling] = true
14
+ end
15
+
16
+ # Stops journaling and returns the journal: [name, value] pairs in
17
+ # capture order, including captures from branches that later failed
18
+ # (best-effort superset — same semantics as the rs recovery
19
+ # journal).
20
+ def stop_journal
21
+ Thread.current[:parsanol_recovery_journaling] = false
22
+ journal
23
+ end
24
+
25
+ def journal
26
+ Thread.current[:parsanol_recovery_journal] || []
27
+ end
28
+
29
+ def journaling?
30
+ Thread.current[:parsanol_recovery_journaling] == true
31
+ end
32
+ end
33
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = "1.3.95"
4
+ VERSION = "1.3.97"
5
5
  end
data/lib/parsanol.rb CHANGED
@@ -273,6 +273,7 @@ module Parsanol
273
273
  end
274
274
  module_function :exp
275
275
 
276
+ autoload :Recovery, "parsanol/recovery"
276
277
  autoload :Resettable, "parsanol/resettable"
277
278
  autoload :Result, "parsanol/result"
278
279
  autoload :Slice, "parsanol/slice"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parsanol
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.95
4
+ version: 1.3.97
5
5
  platform: arm-linux
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-19 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A small Ruby library for constructing parsers in the PEG (Parsing Expression
14
14
  Grammar) fashion. Parsanol provides Parslet-compatible API with additional features
@@ -133,6 +133,7 @@ files:
133
133
  - lib/parsanol/pools/position_pool.rb
134
134
  - lib/parsanol/pools/slice_pool.rb
135
135
  - lib/parsanol/position.rb
136
+ - lib/parsanol/recovery.rb
136
137
  - lib/parsanol/resettable.rb
137
138
  - lib/parsanol/result.rb
138
139
  - lib/parsanol/result_builder.rb