parsanol 1.3.107-arm-linux → 1.3.109-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.
@@ -9,6 +9,15 @@ module Parsanol
9
9
  # Lints use the compiler only as an analysis facade (find_left_cycle,
10
10
  # first_set, nullable?).
11
11
  module Lints
12
+ # Advisory warnings are author guidance — they never ride the
13
+ # envelope. The envelope's lint.order_warnings is a wire contract:
14
+ # recompiling an unchanged grammar must reproduce it byte-for-byte,
15
+ # so style/authoring findings (which fire on perfectly valid
16
+ # grammars) stay out of it.
17
+ Advisory = Struct.new(:message) do
18
+ def to_s = message
19
+ end
20
+
12
21
  class << self
13
22
  def register(name, strategy)
14
23
  strategies[name.to_sym] = strategy
@@ -22,6 +31,14 @@ module Parsanol
22
31
  strategies.values.flat_map { |strategy| strategy.warnings(document, compiler) }
23
32
  end
24
33
 
34
+ # The envelope-carried subset: strategies that are not advisory.
35
+ def envelope_warnings(document, compiler)
36
+ strategies.values
37
+ .reject { |strategy| strategy.respond_to?(:advisory?) && strategy.advisory? }
38
+ .flat_map { |strategy| strategy.warnings(document, compiler) }
39
+ .grep_v(Advisory)
40
+ end
41
+
25
42
  def strategies
26
43
  @strategies ||= {}
27
44
  end
@@ -71,8 +88,19 @@ module Parsanol
71
88
  branches = node.a
72
89
  branches.each_with_index do |branch, index|
73
90
  if compiler.nullable?(branch) && index < branches.length - 1
74
- results << { errors: [empty_shadow_error(name, index + 1)], warnings: [] }
91
+ later = branches[(index + 1)..]
92
+ results << if later.all? { |candidate| compiler.nullable?(candidate) }
93
+ # Both sides always succeed: the later branches are dead
94
+ # code, but acceptance is identical. Imported grammars
95
+ # legitimately produce this (unordered ABNF choice), so
96
+ # it is a warning, not the shadowing hazard.
97
+ { errors: [],
98
+ warnings: [Advisory.new(dead_nullable_warning(name, index + 1))] }
99
+ else
100
+ { errors: [empty_shadow_error(name, index + 1)], warnings: [] }
101
+ end
75
102
  end
103
+
76
104
  branches[(index + 1)..].each_with_index do |later, j|
77
105
  results << compare_branches(name, branch, index + 1, later, index + j + 2, compiler)
78
106
  end
@@ -85,14 +113,25 @@ module Parsanol
85
113
  "shadows all later branches"
86
114
  end
87
115
 
116
+ def dead_nullable_warning(name, branch_number)
117
+ "rule #{name}: branches after #{branch_number} are unreachable " \
118
+ "(that branch always succeeds) — dead code, acceptance unchanged"
119
+ end
120
+
88
121
  def compare_branches(name, earlier, earlier_n, later, later_n, compiler)
89
122
  earlier_lit = literal_string(earlier)
90
123
  later_lit = literal_string(later)
124
+ earlier_fold = literal_fold(earlier)
91
125
  if earlier_lit && later_lit
92
126
  if earlier_lit == later_lit
93
127
  return { errors: ["rule #{name}: branches #{earlier_n} and #{later_n} are identical"], warnings: [] }
94
128
  elsif later_lit.start_with?(earlier_lit)
95
129
  return { errors: [shadow_error(name, earlier_n, earlier_lit, later_n, later_lit)], warnings: [] }
130
+ elsif earlier_fold && earlier_lit.casecmp(later_lit).zero?
131
+ # A case-insensitive earlier literal matches every casing
132
+ # the later literal could: the later branch is dead (same
133
+ # class as the prefix shadow above).
134
+ return { errors: [case_shadow_error(name, earlier_n, earlier_lit, later_n, later_lit)], warnings: [] }
96
135
  end
97
136
  end
98
137
  first_earlier = compiler.first_set(earlier) - [Compiler::EPS]
@@ -116,6 +155,16 @@ module Parsanol
116
155
  "rule #{name}: branches #{earlier_n} and #{later_n} are order-dependent (#{reason})"
117
156
  end
118
157
 
158
+ def literal_fold(node)
159
+ node.kind == :lit ? node.b : nil
160
+ end
161
+
162
+ def case_shadow_error(name, earlier_n, earlier_lit, later_n, later_lit)
163
+ "rule #{name}: branch #{later_n} (#{later_lit.inspect}) can never match -- " \
164
+ "branch #{earlier_n} is case-insensitive and already matches " \
165
+ "#{earlier_lit.inspect} in every casing"
166
+ end
167
+
119
168
  def literal_string(node)
120
169
  case node.kind
121
170
  when :lit then node.a
@@ -125,8 +174,276 @@ module Parsanol
125
174
  end
126
175
  end
127
176
 
177
+ # TODO.sota/9 T1/T2 lints (ruby#307): the core-rule prelude and
178
+ # the brace form both carry a migration path — the lint carries
179
+ # the suggestion. Core-rule shadows are deliberate-compatibility
180
+ # (a local rule wins), so they are warnings, never errors.
181
+ class CoreRuleStyle
182
+ def advisory? = true
183
+
184
+ CORE_NAMES = (
185
+ Compiler::BUILTIN_CLASSES.keys + Compiler::STRUCTURED_CORE_RULE_NAMES
186
+ ).freeze
187
+
188
+ def warnings(document, _compiler)
189
+ authored = document.authored_rules || document.rules.keys
190
+ shadows = authored.select { |name| CORE_NAMES.include?(name) }
191
+ shadow_warnings = shadows.map do |name|
192
+ "rule #{name}: shadows the RFC 5234 core rule — drop the local " \
193
+ "definition unless the narrowing is deliberate"
194
+ end
195
+ shadow_warnings + style_mixing_warning(document)
196
+ end
197
+
198
+ def errors(_document, _compiler) = []
199
+
200
+ private
201
+
202
+ def style_mixing_warning(document)
203
+ brace = document.brace_rules.size
204
+ line = document.rules.size - brace
205
+ return [] if brace.zero? || line.zero?
206
+ return [] if document.rules.size < 4
207
+
208
+ ["grammar mixes brace (#{brace}) and line (#{line}) rule bodies — " \
209
+ "pick one style per grammar"]
210
+ end
211
+ end
212
+
128
213
  register :left_recursion, LeftRecursion.new
214
+ register :core_rule_style, CoreRuleStyle.new
215
+
216
+ # ruby#307: authoring-contract lints. Each flags a grammar whose
217
+ # artifact ships without something its consumers will want: pins,
218
+ # docs, or a canonical entry placement. All warnings — the
219
+ # constructs are legal; the omissions are the hazard.
220
+ class AuthoringContracts
221
+ def advisory? = true
222
+
223
+ def warnings(document, compiler)
224
+ unpinned(document) + undocumented(document, compiler)
225
+ end
226
+
227
+ def errors(_document, _compiler) = []
228
+
229
+ private
230
+
231
+ def unpinned(document)
232
+ return [] unless document.tests.empty?
233
+
234
+ ["grammar ships without a test section — an unpinned grammar is " \
235
+ "unpinned behavior (accept/reject/capture tests ride the artifact " \
236
+ "to every engine)"]
237
+ end
238
+
239
+ def undocumented(document, _compiler)
240
+ # Injected core rules and left-recursion tier rules are
241
+ # compiler-generated: they are reachable and necessarily
242
+ # undocumented, and flagging them would be noise.
243
+ injected = Compiler::BUILTIN_CLASSES.keys +
244
+ Compiler::STRUCTURED_CORE_RULE_NAMES
245
+ authored = document.authored_rules
246
+ prefix = Compiler::LEFT_RECURSION_PREFIX
247
+ entry_reachable(document).filter_map do |name|
248
+ next if authored && !authored.include?(name)
249
+ next if document.docs.key?(name)
250
+ next if injected.include?(name)
251
+ next if name.to_s.start_with?(prefix)
252
+
253
+ "rule #{name}: entry-reachable without a ## doc comment — the " \
254
+ "artifact ships docs for LSP hovers; a public rule without one " \
255
+ "is a missing contract"
256
+ end
257
+ end
258
+
259
+ # Rules reachable from any entry root through rule references.
260
+ def entry_reachable(document)
261
+ reachable = []
262
+ seen = {}
263
+ walk = lambda do |node|
264
+ case node.kind
265
+ when :ref
266
+ name = node.a
267
+ next if seen[name]
268
+
269
+ seen[name] = true
270
+ reachable << name
271
+ walk.call(document.rules[name]) if document.rules.key?(name)
272
+ when :alt, :seq then node.a.each { |child| walk.call(child) }
273
+ when :cap, :pred then walk.call(node.b)
274
+ when :rep, :opt then walk.call(node.a)
275
+ end
276
+ end
277
+ document.entries.each_value do |root|
278
+ walk.call(document.rules[root]) if document.rules.key?(root)
279
+ end
280
+ reachable
281
+ end
282
+ end
283
+
284
+ register :authoring_contracts, AuthoringContracts.new
285
+ # ruby#308 first slice: the two halves of binding coverage.
286
+ # A binding that names a capture the grammar never produces binds
287
+ # nothing; a capture no binding consumes is dead weight. Both are
288
+ # advisory: the grammar compiles either way.
289
+ class BindingCoverage
290
+ def advisory? = true
291
+
292
+ def warnings(document, _compiler)
293
+ all_captures = produced_captures(document)
294
+ bound = []
295
+ document.bindings.each_value do |list|
296
+ Array(list).each { |binding| bound << binding.capture }
297
+ end
298
+
299
+ # No bindings section = the author opted out of binding (a
300
+ # parse-only grammar): uncovered captures are not oversights.
301
+ # The orphan half moved to BindingPaths, whose resolution
302
+ # failures fail the build (ruby#308 v2) -- a warning could
303
+ # never fire past that error.
304
+ dead = document.bindings.empty? ? [] : all_captures - bound.uniq
305
+ dead.map do |capture|
306
+ "capture #{capture.inspect} is bound by no bindings section -- " \
307
+ "ship the binding beside the capture, or drop the capture"
308
+ end
309
+ end
310
+
311
+ def errors(_document, _compiler) = []
312
+
313
+ private
314
+
315
+ def produced_captures(document)
316
+ captures = []
317
+ seen = {}
318
+ document.rules.each_value do |node|
319
+ collect_captures(node, document, captures, seen)
320
+ end
321
+ captures.uniq
322
+ end
323
+
324
+ def collect_captures(node, document, captures, seen)
325
+ case node.kind
326
+ when :cap
327
+ captures << node.a
328
+ collect_captures(node.b, document, captures, seen)
329
+ when :ref
330
+ rule = node.a
331
+ return if seen[rule]
332
+
333
+ seen[rule] = true
334
+ target = document.rules[rule]
335
+ collect_captures(target, document, captures, seen) if target
336
+ when :alt, :seq
337
+ node.a.each { |child| collect_captures(child, document, captures, seen) }
338
+ when :pred
339
+ collect_captures(node.b, document, captures, seen)
340
+ when :rep, :opt
341
+ collect_captures(node.a, document, captures, seen)
342
+ end
343
+ end
344
+ end
345
+
129
346
  register :alternatives, Alternatives.new
347
+ register :binding_coverage, BindingCoverage.new
348
+
349
+ # ruby#308 v2: compile-time binding resolution. The CAPTURE a
350
+ # binding reads must exist in its rule's capture subtree (the
351
+ # orphan check, now with teeth); a `x[]...` path needs that
352
+ # capture to sit under a repetition (the flattening presumes
353
+ # iteration). The path's names are output naming -- free -- but
354
+ # its SHAPE is a contract: one `[]` level, one dotted-free leaf,
355
+ # and a nested path always carries the marker. Anything the
356
+ # runtime would silently bind as nothing fails the build instead,
357
+ # naming the rule, the path, and the offending segment.
358
+ class BindingPaths
359
+ def errors(document, _compiler)
360
+ document.bindings.flat_map do |rule, bindings|
361
+ tree = CaptureTree.new(document, rule)
362
+ Array(bindings).flat_map { |binding| resolve(rule, binding, tree) }
363
+ end
364
+ end
365
+
366
+ def warnings(_document, _compiler) = []
367
+ def advisory? = false
368
+
369
+ private
370
+
371
+ # The validated language is what the runtime honors today:
372
+ # `capture` and `capture[].leaf` -- one repetition level, one
373
+ # leaf. Anything else fails the build instead of silently
374
+ # binding nothing (the runtime's leaf lookup would miss).
375
+ def resolve(rule, binding, tree)
376
+ capture = binding.capture.to_s
377
+ path = binding.path.to_s
378
+ return [unresolvable(rule, path, path)] if path.include?(".") && !path.include?("[]")
379
+
380
+ scope = tree.scope
381
+ return [unresolvable(rule, capture, capture)] unless scope.key?(capture)
382
+ return [] unless path.include?("[]")
383
+
384
+ levels = path.split("[]", -1)
385
+ return [unresolvable(rule, path, "[]")] if levels.size > 2
386
+
387
+ leaf = levels[1].to_s.delete_prefix(".")
388
+ return [unresolvable(rule, path, "[]")] unless scope[capture][0]
389
+ return [unresolvable(rule, path, leaf)] if leaf.empty? || leaf.include?(".")
390
+
391
+ []
392
+ end
393
+
394
+ def unresolvable(rule, path, segment)
395
+ "binding on #{rule.inspect}: path #{path.inspect} does not resolve -- " \
396
+ "segment #{segment.inspect} names no capture at that position " \
397
+ "(supported: capture, capture[].leaf)"
398
+ end
399
+ end
400
+
401
+ # The capture TREE a binding path resolves against: per capture,
402
+ # whether it sits under a repetition and which captures its
403
+ # subtree contains (transitively -- the runtime's capture lookup
404
+ # is subtree-wide too).
405
+ class CaptureTree
406
+ def initialize(document, rule)
407
+ @document = document
408
+ @scope = Hash.new { |hash, name| hash[name] = [false, []] }
409
+ walk(document.rules[rule], { rule => true }) if document.rules.key?(rule)
410
+ end
411
+
412
+ def scope
413
+ @scope
414
+ end
415
+
416
+ def walk(node, seen, under_rep: false, inside: [])
417
+ case node.kind
418
+ when :cap
419
+ entry = @scope[node.a]
420
+ # `1*x as n` compiles to cap(n, rep): the capture WRAPS its
421
+ # repetition, so a repetition in either position makes the
422
+ # capture repeated.
423
+ repeated = under_rep || node.b&.kind == :rep
424
+ entry[0] ||= repeated
425
+ inside.each { |parent| entry[1] |= [parent] unless entry[1].include?(parent) }
426
+ walk(node.b, seen, under_rep: repeated, inside: inside + [node.a])
427
+ when :ref
428
+ rule = node.a
429
+ return if seen[rule]
430
+
431
+ seen[rule] = true
432
+ target = @document.rules[rule]
433
+ walk(target, seen, under_rep: under_rep, inside: inside) if target
434
+ when :alt, :seq
435
+ node.a.each { |child| walk(child, seen, under_rep: under_rep, inside: inside) }
436
+ when :pred
437
+ walk(node.b, seen, under_rep: under_rep, inside: inside)
438
+ when :rep
439
+ walk(node.a, seen, under_rep: true, inside: inside)
440
+ when :opt
441
+ walk(node.a, seen, under_rep: under_rep, inside: inside)
442
+ end
443
+ end
444
+ end
445
+
446
+ register :binding_paths, BindingPaths.new
130
447
 
131
448
  # An unbounded repetition whose body can match empty input is an
132
449
  # INVALID GRAMMAR: the parse can never terminate, and any VM that
@@ -166,6 +483,136 @@ module Parsanol
166
483
  end
167
484
  end
168
485
  register :repetitions, Repetitions.new
486
+
487
+ # ruby#307 lint completion: a rule no other rule references is
488
+ # dead weight (unless it carries an entry); a rule referenced
489
+ # exactly once is an inline candidate. Both advisory -- the
490
+ # grammar is correct either way. Entry roots, the injected core
491
+ # rules, and the left-recursion tier rules are exempt.
492
+ class RuleUsage
493
+ def advisory? = true
494
+
495
+ def warnings(document, _compiler)
496
+ counts = Hash.new(0)
497
+ document.rules.each do |name, node|
498
+ collect_refs(node, document, { name => true }, counts)
499
+ end
500
+
501
+ injected = Compiler::BUILTIN_CLASSES.keys +
502
+ Compiler::STRUCTURED_CORE_RULE_NAMES
503
+ resync_prefix = Compiler::LEFT_RECURSION_PREFIX
504
+ document.rules.filter_map do |name, _node|
505
+ next if injected.include?(name)
506
+ next if name.to_s.start_with?(resync_prefix)
507
+ next if document.entries.value?(name)
508
+
509
+ case counts[name]
510
+ when 0
511
+ "rule #{name.inspect} is referenced by no rule -- dead " \
512
+ "unless it is a future entry; drop it or wire it in"
513
+ when 1
514
+ "rule #{name.inspect} has exactly one reference -- inline " \
515
+ "candidate (keep it if the name carries the domain)"
516
+ end
517
+ end
518
+ end
519
+
520
+ def errors(_document, _compiler) = []
521
+
522
+ private
523
+
524
+ def collect_refs(node, document, seen, counts)
525
+ case node.kind
526
+ when :ref
527
+ rule = node.a
528
+ counts[rule] += 1
529
+ return if seen[rule]
530
+
531
+ seen[rule] = true
532
+ target = document.rules[rule]
533
+ collect_refs(target, document, seen, counts) if target
534
+ when :alt, :seq
535
+ node.a.each { |child| collect_refs(child, document, seen, counts) }
536
+ when :cap, :pred
537
+ collect_refs(node.b, document, seen, counts)
538
+ when :rep, :opt
539
+ collect_refs(node.a, document, seen, counts)
540
+ end
541
+ end
542
+ end
543
+ register :rule_usage, RuleUsage.new
544
+
545
+ # ruby#307: canonical entry placement. Every corpus grammar and
546
+ # the guide's canonical demo declare entries at the FOOT of the
547
+ # grammar block, after the rules they root; an entry above its
548
+ # rule reads as a forward table-of-contents and breaks the
549
+ # reading order. Advisory — placement is style, resolution is
550
+ # order-independent.
551
+ class EntryPlacement
552
+ def advisory? = true
553
+
554
+ def warnings(document, _compiler)
555
+ index = {}
556
+ document.declaration_order.each_with_index do |(kind, name), i|
557
+ index[[kind, name]] = i
558
+ end
559
+ document.entries.filter_map do |name, rule|
560
+ entry_at = index[[:entry, name]]
561
+ rule_at = index[[:rule, rule]]
562
+ next if entry_at.nil? || rule_at.nil? || entry_at > rule_at
563
+
564
+ "entry #{name.inspect} is declared before the rule #{rule.inspect} it " \
565
+ "roots -- canonical placement: entries at the foot of the grammar block"
566
+ end
567
+ end
568
+
569
+ def errors(_document, _compiler) = []
570
+ end
571
+ register :entry_placement, EntryPlacement.new
572
+
573
+ # ruby#307 B5 --suggest-upgrades: machine-applicable authoring
574
+ # upgrades. NOT registered -- its output is a suggestion surface
575
+ # (parg lint --suggest-upgrades), never a compile diagnostic and
576
+ # never part of the envelope.
577
+ class CoreRuleDuplicates
578
+ def warnings(document, _compiler)
579
+ document.rules.filter_map do |name, node|
580
+ next if Compiler::BUILTIN_CLASSES.key?(name)
581
+
582
+ core = core_rule_for(node)
583
+ next if core.nil?
584
+
585
+ "rule #{name.inspect} duplicates the core rule #{core} -- " \
586
+ "reference #{core} instead (or keep the local spelling deliberate)"
587
+ end
588
+ end
589
+
590
+ def errors(_document, _compiler) = []
591
+ def advisory? = true
592
+
593
+ private
594
+
595
+ def core_rule_for(node)
596
+ case node.kind
597
+ when :class
598
+ Compiler::BUILTIN_CLASSES.find { |_, ranges| normalized(ranges) == normalized(node.a) }&.first
599
+ when :seq
600
+ crlf?(node) ? "CRLF" : nil
601
+ end
602
+ end
603
+
604
+ def crlf?(node)
605
+ parts = node.a
606
+ parts.length == 2 &&
607
+ parts.all? { |part| part.kind == :class } &&
608
+ normalized(parts[0].a) == [[0x0D, 0x0D]] &&
609
+ normalized(parts[1].a) == [[0x0A, 0x0A]]
610
+ end
611
+
612
+ def normalized(ranges)
613
+ ranges.map(&:sort).sort
614
+ end
615
+ end
169
616
  end
170
617
  end
171
618
  end
@@ -27,6 +27,7 @@ module Parsanol
27
27
  @tokens = Lexer.new(text).tokens
28
28
  @pos = 0
29
29
  @source_text = text
30
+ @brace_depth = 0
30
31
  end
31
32
 
32
33
  def parse
@@ -65,6 +66,20 @@ module Parsanol
65
66
  advance while peek&.type == :newline
66
67
  end
67
68
 
69
+ # Whitespace inside a brace body: newlines and comments (the `##`
70
+ # doc token included — inside a body it is a comment, not a doc
71
+ # attachment). No-op outside braces, so line-mode parsing is
72
+ # unchanged.
73
+ def skip_body_ws
74
+ return unless @brace_depth.positive?
75
+
76
+ skip_newlines
77
+ while peek&.type == :doc
78
+ advance
79
+ skip_newlines
80
+ end
81
+ end
82
+
68
83
  # Doc comments (##) accumulate and attach to the next rule.
69
84
  def take_doc_comments
70
85
  docs = []
@@ -294,12 +309,41 @@ module Parsanol
294
309
  name = rule_name
295
310
  document.docs[name] = docs.join("\n") unless docs.empty?
296
311
  punct("=")
312
+ # Brace bodies (TODO.sota/9 T2, ruby#302): `name = { ... }`
313
+ # is self-delimiting — newlines and comments are whitespace
314
+ # inside, the body extends to the matching `}`. Additive: `{`
315
+ # never appeared at the rule head before.
316
+ brace = peek&.type == :punct && peek.value == "{"
317
+ if !brace
318
+ # The brace may also sit on the next line: `name =` NL `{`.
319
+ # Peek past the newline; restore when it is not a brace so
320
+ # line-mode error behavior is untouched.
321
+ saved = @pos
322
+ skip_newlines
323
+ if peek&.type == :punct && peek.value == "{"
324
+ brace = true
325
+ else
326
+ @pos = saved
327
+ end
328
+ end
329
+ if brace
330
+ advance
331
+ @brace_depth += 1
332
+ skip_body_ws
333
+ end
297
334
  node = parse_choice
335
+ if brace
336
+ skip_body_ws
337
+ punct("}")
338
+ @brace_depth -= 1
339
+ end
298
340
  if document.rules.key?(name)
299
341
  raise ParseError, "duplicate rule #{name.inspect}"
300
342
  end
301
343
 
302
344
  document.rules[name] = node
345
+ document.declaration_order << [:rule, name]
346
+ document.brace_rules << name if brace
303
347
  document.atomic_rules << name if atomic
304
348
  skip_newlines
305
349
  end
@@ -365,7 +409,10 @@ module Parsanol
365
409
 
366
410
  def parse_choice
367
411
  branches = [parse_seq]
368
- while peek&.type == :punct && peek.value == "/"
412
+ loop do
413
+ skip_body_ws
414
+ break unless peek&.type == :punct && peek.value == "/"
415
+
369
416
  advance
370
417
  branches << parse_seq
371
418
  end
@@ -373,15 +420,22 @@ module Parsanol
373
420
  end
374
421
 
375
422
  def parse_seq
423
+ skip_body_ws
376
424
  items = [parse_element]
377
425
  until stop_element?
426
+ skip_body_ws
427
+ # After skipped body whitespace the terminator (`}`, `)`, `/`)
428
+ # may only now be visible: re-check before parsing an element.
429
+ break if stop_element?
430
+ break if @brace_depth.positive? && peek&.type == :punct && peek.value == "/"
431
+
378
432
  items << parse_element
379
433
  end
380
434
  items.length == 1 ? items.first : Node.new(:seq, items)
381
435
  end
382
436
 
383
437
  def stop_element?
384
- peek.nil? || peek.type == :newline ||
438
+ peek.nil? || (peek.type == :newline && @brace_depth.zero?) ||
385
439
  (peek.type == :punct && ["/", ")", "]", "}"].include?(peek.value)) ||
386
440
  # `capture:` suffix terminates the skip declaration's RHS
387
441
  # (parsanol-ruby#152); everywhere else parse_element's guard
@@ -452,6 +506,7 @@ module Parsanol
452
506
  end
453
507
 
454
508
  def capture_postfix(node)
509
+ skip_body_ws
455
510
  if peek&.type == :ident && peek.value == "as"
456
511
  advance
457
512
  node = Node.new(:cap, ident.value, node)
@@ -477,6 +532,7 @@ module Parsanol
477
532
  when :str, :sstr then Node.new(:lit, unquote(token.value), false)
478
533
  when :istr then Node.new(:lit, unquote(token.value), true)
479
534
  when :hex then parse_hex(token.value)
535
+ when :category then Node.new(:category, token.value[3..-2])
480
536
  when :punct then parse_punctuated(token)
481
537
  when :ident then parse_ident(token)
482
538
  else raise ParseError, "unexpected token #{token.type}:#{token.value}"
@@ -580,6 +636,7 @@ module Parsanol
580
636
  name = ident.value
581
637
  punct(":")
582
638
  document.entries[name] = rule_name
639
+ document.declaration_order << [:entry, name]
583
640
  end
584
641
 
585
642
  def parse_bindings(document)
data/lib/parsanol/parg.rb CHANGED
@@ -18,6 +18,7 @@ module Parsanol
18
18
  autoload :CompileError, "parsanol/parg/error"
19
19
  autoload :ArtifactError, "parsanol/parg/error"
20
20
  autoload :Node, "parsanol/parg/node"
21
+ autoload :Export, "parsanol/parg/export"
21
22
  autoload :Lexer, "parsanol/parg/lexer"
22
23
  autoload :Parser, "parsanol/parg/parser"
23
24
  autoload :Document, "parsanol/parg/document"
@@ -33,6 +34,7 @@ module Parsanol
33
34
  autoload :CLI, "parsanol/parg/cli"
34
35
  autoload :Lsp, "parsanol/parg/lsp"
35
36
  autoload :Lints, "parsanol/parg/lints"
37
+ autoload :Diff, "parsanol/parg/diff"
36
38
  autoload :Preprocess, "parsanol/parg/preprocess"
37
39
  autoload :Visitor, "parsanol/parg/visitor"
38
40
  autoload :Imports, "parsanol/parg/imports"
@@ -143,12 +143,21 @@ module Parsanol
143
143
  # Unified line/column computation:
144
144
  # - String input: compute from input string
145
145
  # - Source / LineCache: delegate
146
+ def prefix_bytes(pos)
147
+ (@input.byteslice(0, pos) || "").b
148
+ end
149
+
146
150
  def line_and_column_at(pos)
147
151
  if @input.is_a?(String)
148
- prefix = @input.byteslice(0, pos) || ""
149
- line = 1 + prefix.count("\n")
150
- last_nl = prefix.rindex("\n")
151
- column = last_nl ? pos - last_nl : pos + 1
152
+ # All index math in BINARY (rindex returns a character index on
153
+ # a text string — mixing it with byteslice was the off-by-one);
154
+ # the column is a CHARACTER count (the guide's wire contract;
155
+ # rs walks chars()). Offsets stay bytes.
156
+ raw = prefix_bytes(pos)
157
+ line = 1 + raw.count("\n".b)
158
+ last_nl = raw.rindex("\n".b)
159
+ tail = last_nl ? raw.byteslice(last_nl + 1, pos - last_nl - 1) : raw
160
+ column = tail.force_encoding(Encoding::UTF_8).length + 1
152
161
  [line, column]
153
162
  else
154
163
  @input.line_and_column(pos)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parsanol
4
- VERSION = "1.3.107"
4
+ VERSION = "1.3.109"
5
5
  end