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
cargo test— all existing unit tests pass- New unit tests per interpolation pattern:
$.varNameinsidef"text {$.count} more"(Python)$.varNameinside`text ${$.count} more`(TypeScript)- Nested braces:
f"{d[$.key]}"(Python) - Escaped delimiters:
f"{{not interp}}"(Python) - Same patterns for
#{}(Ruby),\()(Swift),${}(Kotlin/Dart/C#)
- Recipe 48 (Launch Controller)
$.t_minusin f-string works without workaround make test— full 17-language integration suite, 0 failures- Existing string-heavy tests pass (strings without Frame constructs still skip correctly)
Implementation Order
- Add
InterpRegionstruct and trait method to unified.rs - Write Python
fstring_scanner.frs, compile, wire up inpython.rs - Validate with Recipe 48 —
$.t_minusin f-string - Write remaining 4 scanner
.frsfiles - Wire up 7 remaining language modules
- Full test suite validation