Skip to content

Commit cc65e51

Browse files
committed
feat(isc): capture/maybe/ref/+ support, sub block form, notes heredoc fixes
Grammar: - Add capture(...), maybe(...), ref(N) item constructs - Add + concat operator with item_continuation lookahead - Accept comma-separated lists in any([...]) (codemod preserves commas) Codemod: - Emit block form sub { from ... to ... before ... } when from/to contain concat, capture, maybe, or any() — compact form reserved for single atoms - Handle notes: heredoc form (|-style multi-line notes) - Handle blank-line-separated list items in notes blocks - Handle leading whitespace before subsequent - items after blank lines - Strip colons from before:/after:/not_before:/not_after: kwargs Verification status: 146/289 maps (50%) produce equivalent semantic output vs the Ruby DSL. Remaining failures cluster around edge cases in multi-block sub rules with comments and complex constraints.
1 parent 70057af commit cc65e51

4 files changed

Lines changed: 261 additions & 60 deletions

File tree

exe/codemod-imp-to-isc

Lines changed: 161 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -279,53 +279,59 @@ module Interscript
279279

280280
if @scanner.check(dedent_check)
281281
return
282-
elsif @scanner.check(/\n[ \t]{0,#{indent.length}}\}/)
283-
# Hit the enclosing metadata `}` — stop here, let the metadata
284-
# loop's `\}` rule close it.
282+
elsif @scanner.check(/\n(?:[ \t]*\n)*[ \t]{0,#{indent.length}}\}/)
283+
# Hit the enclosing metadata `}` (possibly after blank lines).
285284
return
286-
elsif @scanner.scan(/\n([ \t]+)-\s+\|\s*\n/)
285+
elsif @scanner.scan(/\n[ \t]*\n/)
286+
# Blank line(s) between items — preserve one newline. Do NOT
287+
# consume the indent of the next item; the next-item regexes
288+
# require the indent prefix.
289+
@out << "\n"
290+
elsif @scanner.scan(/\n([ \t]+)-[ \t]*\|[ \t]*\n/)
291+
# `|` heredoc form
287292
note_indent = @scanner[1]
288293
@out << "\n#{note_indent}note \""
289294
read_heredoc_into_string(note_indent)
290295
@out << "\""
291-
elsif @scanner.scan(/\n([ \t]+)-\s+/)
296+
elsif @scanner.scan(/\n([ \t]+)-[ \t]+/)
297+
# Single-line item start (possibly with continuation lines).
292298
note_indent = @scanner[1]
293-
@out << "\n#{note_indent}note \""
294-
text = @scanner.scan(/[^\n]+/).to_s
295-
@out << text.gsub('"', '\\"')
296-
# Consume continuation lines: any subsequent line indented deeper
297-
# than the `- ` marker is part of the same note.
298-
while @scanner.check(/\n[ \t]{#{note_indent.length + 1},}\S/)
299-
@scanner.scan(/\n([ \t]+)/)
300-
@out << "\\n" + @scanner[1].strip + " "
301-
cont = @scanner.scan(/[^\n]+/).to_s
302-
@out << cont.gsub('"', '\\"')
303-
end
304-
@out << "\""
305-
elsif @scanner.scan(/\n[ \t]*\n/)
306-
@out << @scanner.matched
299+
emit_note_with_continuation(note_indent)
300+
elsif @scanner.scan(/([ \t]+)-[ \t]*\|[ \t]*\n/)
301+
# First item right after `notes:` consumed; scanner at `<indent>- |\n`.
302+
emit_heredoc_note(@scanner[1])
303+
elsif @scanner.scan(/([ \t]+)-[ \t]+/)
304+
# First item right after `notes:` consumed; scanner at `<indent>- item`.
305+
emit_note_with_continuation(@scanner[1])
307306
elsif @scanner.scan(/\n/)
308307
@out << "\n"
309308
else
310-
# First item right after `notes: ` consumed; scanner at `- item`.
311-
if @scanner.scan(/-\s+/)
312-
@out << "\n#{indent}note \""
313-
text = @scanner.scan(/[^\n]+/).to_s
314-
@out << text.gsub('"', '\\"')
315-
while @scanner.check(/\n[ \t]{#{indent.length + 1},}\S/)
316-
@scanner.scan(/\n([ \t]+)/)
317-
@out << "\\n" + @scanner[1].strip + " "
318-
cont = @scanner.scan(/[^\n]+/).to_s
319-
@out << cont.gsub('"', '\\"')
320-
end
321-
@out << "\""
322-
else
323-
@out << @scanner.getch
324-
end
309+
@out << @scanner.getch
325310
end
326311
end
327312
end
328313

314+
def emit_heredoc_note(indent)
315+
@out << "\n#{indent}note \""
316+
read_heredoc_into_string(indent)
317+
@out << "\""
318+
end
319+
320+
def emit_note_with_continuation(note_indent)
321+
@out << "\n#{note_indent}note \""
322+
text = @scanner.scan(/[^\n]+/).to_s
323+
@out << text.gsub('"', '\\"')
324+
# Consume continuation lines: any subsequent line indented deeper
325+
# than the `- ` marker is part of the same note.
326+
while @scanner.check(/\n[ \t]{#{note_indent.length + 1},}\S/)
327+
@scanner.scan(/\n([ \t]+)/)
328+
@out << "\\n" + @scanner[1].strip + " "
329+
cont = @scanner.scan(/[^\n]+/).to_s
330+
@out << cont.gsub('"', '\\"')
331+
end
332+
@out << "\""
333+
end
334+
329335
def read_heredoc_into_string(indent)
330336
# Read lines that are indented deeper than `indent` (or blank). Concatenate.
331337
until @scanner.eos?
@@ -444,10 +450,127 @@ module Interscript
444450
end
445451

446452
def convert_sub_rule
447-
# `sub "X", "Y", before: Z` -> `sub "X" "Y" before Z`
448-
# `sub "X" => "Y"` -> `sub "X" "Y"`
449-
# Just let the main loop handle the rest; the main loop already drops
450-
# commas, hash rockets, and `key:` colons.
453+
# Read the rule's from, to, and optional constraints from the source.
454+
# The .imp form is one of:
455+
# sub "X", "Y", before: Z (positional + kwargs)
456+
# sub "X" => "Y", before: Z (hash rocket)
457+
# sub "X", "Y" (no constraints)
458+
# sub "X" + any(Y), "Z", before: W (concat in from)
459+
#
460+
# Output: if from/to are simple (single quoted string or atom each),
461+
# emit compact form `sub "X" "Y"`. Otherwise emit block form:
462+
# sub {
463+
# from <expr>
464+
# to <expr>
465+
# before <expr>
466+
# ...
467+
# }
468+
469+
# Tokenize the rule body up to the next `\n` (rules are single-line)
470+
# or unindented `}`. Capture: from_expr, comma, to_expr, constraints.
471+
from_expr, to_expr, constraints_str = tokenize_sub_rule
472+
473+
# Decide compact vs block form.
474+
compact_safe = single_atom?(from_expr) && single_atom?(to_expr) && constraints_str.empty?
475+
476+
if compact_safe
477+
@out << " #{from_expr} #{to_expr}\n"
478+
else
479+
@out << " {\n"
480+
@out << " from #{from_expr}\n" unless from_expr.empty?
481+
@out << " to #{to_expr}\n" unless to_expr.empty?
482+
unless constraints_str.empty?
483+
constraints_str.strip.split(/(?=\b(?:before|after|not_before|not_after)\b)/).each do |c|
484+
@out << " #{c.strip}\n" unless c.strip.empty?
485+
end
486+
end
487+
@out << " }\n"
488+
end
489+
end
490+
491+
# Tokenize a sub rule body. Returns [from, to, constraints_string].
492+
# Advances the scanner past the rule (consumes up to and including the
493+
# trailing newline).
494+
def tokenize_sub_rule
495+
# Read until end of line. Rules are single-line in .imp.
496+
line = @scanner.scan_until(/\n/).to_s
497+
# Drop the trailing newline
498+
line = line.chomp
499+
500+
# Split into tokens: handle hash rockets, commas, parens, strings.
501+
# We do this by walking the string with a simple state machine.
502+
tokens = []
503+
current = +""
504+
in_string = nil
505+
paren_depth = 0
506+
507+
line.each_char.with_index do |c, _i|
508+
if in_string
509+
current << c
510+
if c == in_string && current[-2] != "\\"
511+
in_string = nil
512+
end
513+
elsif c == '"' || c == "'"
514+
in_string = c
515+
current << c
516+
elsif c == "("
517+
paren_depth += 1
518+
current << c
519+
elsif c == ")"
520+
paren_depth -= 1
521+
current << c
522+
elsif paren_depth.zero? && (c == "," || (c == "=" && line[_i + 1] == ">"))
523+
tokens << current.strip
524+
current = +""
525+
# Skip the comma or `=>`
526+
if c == "="
527+
@scanner.unscan if false # can't unscan, line already consumed
528+
end
529+
else
530+
current << c
531+
end
532+
end
533+
tokens << current.strip unless current.strip.empty?
534+
535+
# Drop hash rocket tokens (already handled above by treating `=>` like `,`)
536+
tokens = tokens.reject { |t| t == "=>" }
537+
538+
# First token = from, second = to, rest = constraints
539+
from_expr = normalize_expr(tokens.shift.to_s)
540+
to_expr = normalize_expr(tokens.shift.to_s)
541+
constraints_str = tokens.join(" ")
542+
543+
# Strip the `before:` etc colon (the codemod dropped these elsewhere,
544+
# but here we want to normalize: `before: X` -> `before X`)
545+
constraints_str = constraints_str.gsub(/(before|after|not_before|not_after)\s*:/, '\1')
546+
547+
[from_expr, to_expr, constraints_str]
548+
end
549+
550+
# A "single atom" expression is one quoted string, `none`, `boundary`,
551+
# `line_start`, `line_end`, `word_boundary`, or a bare alias identifier.
552+
# Anything with `+`, `any(`, `capture(`, `maybe(`, or concatenation is
553+
# NOT a single atom.
554+
def single_atom?(expr)
555+
return false if expr.nil? || expr.empty?
556+
return false if expr.include?("+")
557+
return false if expr =~ /\b(any|capture|maybe)\s*\(/
558+
s = expr.strip
559+
return true if s =~ /\A"[^"]*"\z/ || s =~ /\A'[^']*'\z/
560+
return true if ["none", "boundary", "line_start", "line_end", "word_boundary"].include?(s)
561+
return true if s =~ /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
562+
false
563+
end
564+
565+
# Normalize a captured expression: drop redundant whitespace around
566+
# `+` operators. `sub "X" , "Y"` -> tokens ["\"X\"", "\"Y\""].
567+
def normalize_expr(expr)
568+
expr = expr.strip
569+
# Collapse runs of whitespace
570+
expr = expr.gsub(/\s+/, " ")
571+
# Remove space around +
572+
expr = expr.gsub(/\s*\+\s*/, " + ")
573+
expr
451574
end
452575

453576
def convert_run_rule

lib/interscript/isc/grammar/concerns/items.rb

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,13 @@ module Concerns
1010
module Items
1111
include Parslet
1212

13-
# An item atom is one of:
14-
# * quoted string literal
15-
# * keyword none (empty match)
16-
# * zero-width primitive (boundary, line_start, etc.)
17-
# * any(...) constructor — range or set
18-
# * bare identifier — alias reference
19-
# * capture reference \N (valid in target only; parser accepts everywhere
20-
# and semantic layer enforces target-only)
2113
rule(:item_atom) do
2214
quoted_string |
2315
str("none").as(:none) |
2416
zero_width_primitive |
2517
any_constructor |
18+
capture_constructor |
19+
maybe_constructor |
2620
capture_reference |
2721
alias_reference
2822
end
@@ -42,6 +36,21 @@ module Items
4236
whitespace? >> str(")")
4337
end
4438

39+
# capture(...) — wraps a sub-expression with a capture group.
40+
# The captured value can be referenced in the target via `ref(N)`.
41+
rule(:capture_constructor) do
42+
str("capture") >> str("(") >> whitespace? >>
43+
item.as(:capture_inner) >>
44+
whitespace? >> str(")")
45+
end
46+
47+
# maybe(...) — optional match (zero or one occurrence).
48+
rule(:maybe_constructor) do
49+
str("maybe") >> str("(") >> whitespace? >>
50+
item.as(:maybe_inner) >>
51+
whitespace? >> str(")")
52+
end
53+
4554
rule(:range_arg) do
4655
quoted_string.as(:lo) >>
4756
whitespace? >> str("..") >> whitespace? >>
@@ -51,18 +60,19 @@ module Items
5160
rule(:set_arg) do
5261
quoted_string.as(:single) |
5362
(str("[") >> whitespace? >>
54-
(quoted_string >> (whitespace >> quoted_string).repeat).as(:list) >>
63+
(quoted_string >> ((comma | whitespace) >> quoted_string).repeat).as(:list) >>
5564
whitespace? >> str("]"))
5665
end
5766

5867
rule(:alias_reference) do
59-
# An alias reference is an identifier that isn't a reserved keyword
60-
# AND isn't immediately followed by `{` (which would make it a
61-
# block opener like `parallel {`).
6268
(keyword.absent? >> identifier >> str("{").absent?).as(:alias)
6369
end
6470

65-
# Reserved keywords that should never be parsed as alias references.
71+
# ref(N) — reference to Nth capture group. Only valid in `to` position.
72+
rule(:capture_reference) do
73+
(str("ref") >> str("(") >> match(/[0-9]/).as(:digit) >> str(")")).as(:ref)
74+
end
75+
6676
rule(:keyword) do
6777
str("parallel") | str("sequence") | str("stage") |
6878
str("compose") | str("separate") | str("system") |
@@ -73,20 +83,39 @@ module Items
7383
str("not_before") | str("not_after") | str("any") |
7484
str("none") | str("boundary") | str("line_start") |
7585
str("line_end") | str("word_boundary") |
76-
str("downcase") | str("upcase") | str("title_case")
86+
str("downcase") | str("upcase") | str("title_case") |
87+
str("capture") | str("maybe") | str("ref")
7788
end
7889

79-
rule(:capture_reference) do
80-
(str("\\") >> match(/[0-9]/).as(:digit)).as(:capture)
90+
# Concatenation: one or more atoms. The continuation pattern
91+
# requires that whitespace or `+` be IMMEDIATELY followed by
92+
# something that's clearly an item_atom start (a quote, `(`,
93+
# letter, etc.) AND not a block-rule keyword like `to`, `before`.
94+
rule(:item) do
95+
(item_atom >>
96+
((concat_sep >> item_continuation.present?) >> item_atom).repeat
97+
).as(:concatenation)
8198
end
8299

83-
# Concatenation: two or more adjacent atoms (whitespace-separated).
84-
# A single atom is also accepted (degenerate concatenation).
85-
rule(:item) do
86-
(item_atom >> (whitespace >> item_atom).repeat).as(:concatenation)
100+
rule(:concat_sep) do
101+
(whitespace? >> str("+") >> whitespace?) | whitespace
102+
end
103+
104+
# Positive lookahead: the next thing is a valid item_atom continuation.
105+
# Excludes keywords that end the item (to, before, after, not_before,
106+
# not_after, and the closing brace).
107+
rule(:item_continuation) do
108+
(str("to") | str("before") | str("after") |
109+
str("not_before") | str("not_after") |
110+
str("}")).absent? >>
111+
item_atom_start
112+
end
113+
114+
rule(:item_atom_start) do
115+
str('"') | str("'") |
116+
match(/[A-Za-z_]/)
87117
end
88118

89-
# Constraint clauses attached to a rule.
90119
rule(:constraint) do
91120
(
92121
(str("before") >> whitespace >> item.as(:before)) |

lib/interscript/isc/items.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,32 @@ def inspect
7272
end
7373
end
7474

75+
# Wraps a sub-expression that captures its match for later reference via ref(N).
76+
class CaptureGroup
77+
attr_reader :inner
78+
79+
def initialize(inner)
80+
@inner = inner
81+
end
82+
83+
def inspect
84+
"CaptureGroup(#{@inner.inspect})"
85+
end
86+
end
87+
88+
# Wraps an optional sub-expression (matches zero or one time).
89+
class Maybe
90+
attr_reader :inner
91+
92+
def initialize(inner)
93+
@inner = inner
94+
end
95+
96+
def inspect
97+
"Maybe(#{@inner.inspect})"
98+
end
99+
end
100+
75101
class Range
76102
attr_reader :lo, :hi
77103

0 commit comments

Comments
 (0)