RFC-0010: Interpolation-Aware String Scanning via Frame Automata

Prompt Engineer: Mark Truluck mark@frame-lang.org Status: Draft Created: 2026-04-18

Problem

$.varName and @@: constructs inside string interpolation expressions are invisible to the scanner. The root cause: skip_string in the main scanner loop (unified.rs:210) skips entire string literals — including interpolation expressions like f"T-{$.t_minus}". The $. detection at line 219 never fires because the scanner already jumped past it.

The current workaround — assign to a local variable before interpolating — is a hack:

# Workaround (current):
t = $.t_minus
print(f"T-{t}...")

# Desired (after this RFC):
print(f"T-{$.t_minus}...")

This affects 8 of 17 target languages that support string interpolation with embedded expressions.

Design

Core Idea

The string skipper currently returns one position — the end of the string. Instead, it returns the end of the string plus a list of interpolation regions where Frame constructs might live. The main scanner loop scans those regions for $./@@ while skipping the string content between them.

New Trait Method

Add to SyntaxSkipper (unified.rs):

pub struct InterpRegion {
    pub start: usize,  // First byte of expression (after { or ${ etc.)
    pub end: usize,    // Last byte of expression (before } or ) etc.)
}

// Added to SyntaxSkipper trait:
fn string_interp_regions(
    &self, bytes: &[u8], i: usize, end: usize
) -> Option<(usize, Vec<InterpRegion>)> {
    None  // Default: no interpolation support
}

Main Scanner Loop Change

Before the existing skip_string check, try string_interp_regions:

if let Some((str_end, regions)) = skipper.string_interp_regions(bytes, i, end) {
    // Emit native text for string content, scan Frame constructs in regions
    for region in regions {
        // String content before this region → NativeText (skip)
        // Region content → run $./@@ detection on these bytes
    }
    i = str_end;
} else if skipper.skip_string(bytes, i, end).is_some() {
    // Existing behavior — skip entire string
    i = skipper.skip_string(bytes, i, end).unwrap();
}

Five Frame Automata

Each interpolation pattern gets a Frame automaton (.frs file) that scans a string and produces interpolation regions. All share the same shape — three states:

  • $Init — detect the string prefix, identify the quote character
  • $InString — scan string content until close quote or interpolation start
  • $InInterp — track brace/paren depth, record the region, return to $InString
Automaton Languages Prefix Interp Start Interp End File
FStringScanner Python f" / f' { (not {{) } fstring_scanner.frs
TemplateLiteralScanner TypeScript, JavaScript ` ${ } template_literal_scanner.frs
DollarStringScanner Kotlin, Dart, C# $" or " with $ ${ or { } dollar_string_scanner.frs
HashStringScanner Ruby " #{ } hash_string_scanner.frs
ParenStringScanner Swift " \( ) paren_string_scanner.frs

Example Automaton: FStringScanner

@@[target("rust")]

@@system FStringScanner {
    interface:
        scan()

    machine:
        $Init {
            scan() {
                // Check for f" or f' prefix
                if self.bytes[self.pos] == b'f'
                   && self.pos + 1 < self.end
                   && (self.bytes[self.pos + 1] == b'"'
                       || self.bytes[self.pos + 1] == b'\'') {
                    self.quote = self.bytes[self.pos + 1];
                    self.pos = self.pos + 2;
                    -> $InString
                } else {
                    self.success = 0;
                }
            }
        }

        $InString {
            $>() {
                while self.pos < self.end {
                    let b = self.bytes[self.pos];
                    if b == b'\\' {
                        self.pos += 2;
                        continue;
                    }
                    if b == self.quote {
                        self.string_end = self.pos + 1;
                        -> $Done
                    }
                    if b == b'{' {
                        if self.pos + 1 < self.end
                           && self.bytes[self.pos + 1] == b'{' {
                            self.pos += 2;
                            continue;  // {{ escape
                        }
                        -> $InInterp
                    }
                    self.pos += 1;
                }
            }
        }

        $InInterp {
            $>() {
                let interp_start = self.pos + 1;  // after {
                let mut depth: i32 = 1;
                self.pos += 1;
                while self.pos < self.end && depth > 0 {
                    if self.bytes[self.pos] == b'{' { depth += 1; }
                    if self.bytes[self.pos] == b'}' { depth -= 1; }
                    if depth > 0 { self.pos += 1; }
                }
                // self.pos is now at the closing }
                self.regions.push(InterpRegion {
                    start: interp_start,
                    end: self.pos,
                });
                self.pos += 1;  // skip }
                -> $InString
            }
        }

        $Done { }

    domain:
        bytes: Vec<u8> = Vec::new()
        pos: usize = 0
        end: usize = 0
        quote: u8 = 0
        string_end: usize = 0
        regions: Vec<InterpRegion> = Vec::new()
        success: usize = 1
}

Languages Unchanged (9 of 17)

C, C++, Java, Go, Lua, Erlang, GDScript, Rust, PHP — no expression interpolation in strings. Their string_interp_regions returns None (trait default). Their existing skip_string is unchanged.

Note on PHP: PHP interpolates $var in double-quoted strings, but Frame’s $.varName starts with $. (dollar-dot). PHP doesn’t use $. syntax, so no conflict. Skip for now; revisit if needed.

Files Modified

File Change
native_region_scanner/unified.rs InterpRegion struct, trait method, main loop integration
native_region_scanner/fstring_scanner.frs New — Python f-string scanner
native_region_scanner/template_literal_scanner.frs New — JS/TS template literal scanner
native_region_scanner/dollar_string_scanner.frs New — Kotlin/Dart/C# ${} scanner
native_region_scanner/hash_string_scanner.frs New — Ruby #{} scanner
native_region_scanner/paren_string_scanner.frs New — Swift \() scanner
native_region_scanner/python.rs Implement string_interp_regions
native_region_scanner/javascript.rs Implement string_interp_regions
native_region_scanner/typescript.rs Implement string_interp_regions
native_region_scanner/kotlin.rs Implement string_interp_regions
native_region_scanner/dart.rs Implement string_interp_regions
native_region_scanner/csharp.rs Implement string_interp_regions
native_region_scanner/ruby.rs Implement string_interp_regions
native_region_scanner/swift.rs Implement string_interp_regions

Verification

  1. cargo test — all existing unit tests pass
  2. New unit tests per interpolation pattern:
    • $.varName inside f"text {$.count} more" (Python)
    • $.varName inside `text ${$.count} more` (TypeScript)
    • Nested braces: f"{d[$.key]}" (Python)
    • Escaped delimiters: f"{{not interp}}" (Python)
    • Same patterns for #{} (Ruby), \() (Swift), ${} (Kotlin/Dart/C#)
  3. Recipe 48 (Launch Controller) $.t_minus in f-string works without workaround
  4. make test — full 17-language integration suite, 0 failures
  5. Existing string-heavy tests pass (strings without Frame constructs still skip correctly)

Implementation Order

  1. Add InterpRegion struct and trait method to unified.rs
  2. Write Python fstring_scanner.frs, compile, wire up in python.rs
  3. Validate with Recipe 48 — $.t_minus in f-string
  4. Write remaining 4 scanner .frs files
  5. Wire up 7 remaining language modules
  6. Full test suite validation