@@ -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
0 commit comments