Skip to content

Commit 54c3714

Browse files
f-strings: f\"x={n}\" interpolation as parser sugar over concat_many
Second piece of the Python-ergonomics catch-up. Adds the f-string syntax that Python users reach for first when formatting output. Lexer: - FStringPart enum with Literal(String) / Expr(String) variants - Token::FString(Vec<FStringPart>) carries the lexed template - read_fstring() handles brace-balanced expression segments + the Python-compatible `{{` / `}}` escapes for literal braces - Lexer dispatch triggers on `f\"` or `f'` (also `F\"`/`F'`); bare `f` identifiers parse normally Parser: - Token::FString in primary-expression position re-parses each Expr segment via a sub-Parser (Parser::new + parse_expression) and emits Expression::Call { name: \"concat_many\", args } - Literal segments become Expression::String; expression segments become whatever sub-parsing produces (Number, Call, Add, etc.) - concat_many already handles mixed int/float/string args via to_string internally — f\"x={n}\" works for any value type without an explicit to_string call Tests (examples/tests/test_fstrings.omc — 10 tests, all pass): - Simple int / float / string interpolation - Multiple {} segments in one string - Arithmetic inside braces - Function call inside braces (abs(), nth_fibonacci()) - {{ and }} escapes for literal braces - No-interp f-string equivalent to regular string - Empty f-string returns empty string What f-strings DON'T yet support (Python parity work): - Format-spec syntax: f\"{x:.2f}\", f\"{n:>10}\". OMC has explicit formatters (str_pad_left, str_pad_right, etc.) that callers can use inside braces — f\"{str_pad_left(to_string(n), 10, \\\"0\\\")}\" works today and is composable. Format-spec sugar can come later as another parser-side translation. - Multi-line f-strings via \"\"\"...\"\"\". Triple-quoted strings exist; f-prefix on them isn't wired through the lexer yet. - Self-documenting expressions: f\"{n=}\" (Python 3.8+). Easy parser-level rewrite if/when users ask for it. Regression: 8 exception tests + 57 substrate + 70 builtins + 18 harmonic libs + 16 heal + 10 new f-string = 179 OMC tests, all pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 85e2888 commit 54c3714

2 files changed

Lines changed: 192 additions & 0 deletions

File tree

examples/tests/test_fstrings.omc

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Tests for f-string interpolation. `f"..."` syntax is sugar for
2+
# `concat_many(literal_segments, to_string_calls...)`.
3+
4+
fn assert_eq(actual, expected, msg) {
5+
if actual != expected {
6+
test_record_failure(msg + ": expected " + to_string(expected) + " got " + to_string(actual));
7+
}
8+
}
9+
10+
fn test_simple_interp() {
11+
h x = 42;
12+
assert_eq(f"x={x}", "x=42", "simple int interpolation");
13+
}
14+
15+
fn test_multiple_segments() {
16+
h a = 1;
17+
h b = 2;
18+
assert_eq(f"{a}+{b}={a+b}", "1+2=3", "expression interpolation");
19+
}
20+
21+
fn test_no_interp() {
22+
# An f-string with NO {expr} segments is equivalent to a regular string.
23+
assert_eq(f"hello", "hello", "no-interp f-string == regular string");
24+
}
25+
26+
fn test_empty_fstring() {
27+
assert_eq(f"", "", "empty f-string is empty");
28+
}
29+
30+
fn test_string_interp() {
31+
h name = "world";
32+
assert_eq(f"hello {name}", "hello world", "string interpolation");
33+
}
34+
35+
fn test_float_interp() {
36+
h pi = 3.14;
37+
assert_eq(f"pi={pi}", "pi=3.14", "float interpolation");
38+
}
39+
40+
fn test_arithmetic_in_braces() {
41+
h n = 5;
42+
assert_eq(f"{n*2+1}", "11", "arithmetic inside braces");
43+
}
44+
45+
fn test_brace_escape() {
46+
# `{{` and `}}` are escape sequences for literal `{` and `}`.
47+
assert_eq(f"left{{right}}", "left{right}", "{{ and }} escape");
48+
}
49+
50+
fn test_nested_function_call() {
51+
h n = 100;
52+
assert_eq(f"abs={abs(0 - n)}", "abs=100", "function call inside braces");
53+
}
54+
55+
fn test_substrate_primitive_in_braces() {
56+
# f-strings compose with the substrate. nth_fibonacci is JIT-eligible;
57+
# the f-string just calls to_string on the result.
58+
assert_eq(f"fib(11)={nth_fibonacci(11)}", "fib(11)=89", "substrate primitive in f-string");
59+
}

omnimcode-core/src/parser.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
use crate::ast::*;
44
use std::collections::VecDeque;
55

6+
/// One segment of an f-string body. `f"x={n+1} done"` lexes as
7+
/// `[Literal("x="), Expr("n+1"), Literal(" done")]`. The parser
8+
/// re-parses each Expr segment via a sub-Parser to produce a real
9+
/// Expression AST and stitches the parts together via `concat_many`.
10+
#[derive(Clone, Debug, PartialEq)]
11+
pub enum FStringPart {
12+
Literal(String),
13+
Expr(String),
14+
}
15+
616
#[derive(Clone, Debug, PartialEq)]
717
pub enum Token {
818
// Keywords
@@ -31,6 +41,10 @@ pub enum Token {
3141
Finally,
3242
Throw,
3343
Match,
44+
/// f-string template — alternating literal and expression segments.
45+
/// Parser turns this into `concat_many(parts...)` at expression
46+
/// position.
47+
FString(Vec<FStringPart>),
3448
/// `..` for inclusive ranges in match patterns: `0..9`, `"a".."z"`.
3549
/// Lexed when not part of `..=` (which we don't use yet) or `...`.
3650
DotDot,
@@ -220,6 +234,87 @@ impl Lexer {
220234
result
221235
}
222236

237+
/// Read an f-string body — `f"x={n}"` syntax. Splits the body into
238+
/// alternating literal and expression segments at `{...}` markers.
239+
/// The expression segments are stored as raw source strings; the
240+
/// parser later re-parses each via a sub-parser into a real
241+
/// Expression AST. `{{` and `}}` are escape sequences for literal
242+
/// `{` and `}` (Python-compatible).
243+
fn read_fstring(&mut self, quote: char) -> Vec<FStringPart> {
244+
let mut parts: Vec<FStringPart> = Vec::new();
245+
let mut cur_lit = String::new();
246+
self.advance(); // skip opening quote
247+
while let Some(c) = self.current() {
248+
if c == quote {
249+
self.advance();
250+
break;
251+
}
252+
if c == '{' {
253+
// `{{` -> literal `{`
254+
if self.peek(1) == Some('{') {
255+
cur_lit.push('{');
256+
self.advance(); self.advance();
257+
continue;
258+
}
259+
// Flush current literal segment.
260+
if !cur_lit.is_empty() {
261+
parts.push(FStringPart::Literal(std::mem::take(&mut cur_lit)));
262+
}
263+
self.advance(); // consume `{`
264+
let mut depth: i32 = 1;
265+
let mut expr_src = String::new();
266+
while let Some(ec) = self.current() {
267+
if ec == '{' { depth += 1; expr_src.push(ec); self.advance(); continue; }
268+
if ec == '}' {
269+
depth -= 1;
270+
if depth == 0 { self.advance(); break; }
271+
expr_src.push(ec);
272+
self.advance();
273+
continue;
274+
}
275+
expr_src.push(ec);
276+
self.advance();
277+
}
278+
parts.push(FStringPart::Expr(expr_src.trim().to_string()));
279+
continue;
280+
}
281+
if c == '}' {
282+
// `}}` -> literal `}`
283+
if self.peek(1) == Some('}') {
284+
cur_lit.push('}');
285+
self.advance(); self.advance();
286+
continue;
287+
}
288+
// Bare `}` is an error in Python f-strings, but we
289+
// accept it as a literal for ergonomics.
290+
cur_lit.push('}');
291+
self.advance();
292+
continue;
293+
}
294+
if c == '\\' {
295+
self.advance();
296+
match self.current() {
297+
Some('n') => cur_lit.push('\n'),
298+
Some('t') => cur_lit.push('\t'),
299+
Some('r') => cur_lit.push('\r'),
300+
Some('\\') => cur_lit.push('\\'),
301+
Some('"') => cur_lit.push('"'),
302+
Some('\'') => cur_lit.push('\''),
303+
Some(c) => cur_lit.push(c),
304+
None => break,
305+
}
306+
self.advance();
307+
} else {
308+
cur_lit.push(c);
309+
self.advance();
310+
}
311+
}
312+
if !cur_lit.is_empty() {
313+
parts.push(FStringPart::Literal(cur_lit));
314+
}
315+
parts
316+
}
317+
223318
fn read_number(&mut self) -> Token {
224319
let mut num_str = String::new();
225320
let mut is_float = false;
@@ -342,6 +437,15 @@ impl Lexer {
342437
}
343438
Some('\'') => return Token::String(self.read_string('\'')),
344439
Some(c) if c.is_ascii_digit() => return self.read_number(),
440+
// f-string prefix: `f"..."` or `f'...'` (also `F"..."`).
441+
// Triggered ONLY when `f` is directly followed by a
442+
// quote — a bare `f` identifier still parses normally.
443+
Some(c) if (c == 'f' || c == 'F')
444+
&& matches!(self.peek(1), Some('"') | Some('\'')) => {
445+
self.advance(); // consume `f`
446+
let quote = self.current().unwrap();
447+
return Token::FString(self.read_fstring(quote));
448+
}
345449
Some(c) if c.is_alphabetic() || c == '_' => {
346450
let ident = self.read_ident();
347451
return match ident.as_str() {
@@ -1536,6 +1640,35 @@ impl Parser {
15361640
self.advance();
15371641
Ok(Expression::String(val))
15381642
}
1643+
Token::FString(parts) => {
1644+
let parts_copy = parts.clone();
1645+
self.advance();
1646+
// Turn the f-string into `concat_many(seg0, seg1, ...)`
1647+
// where literal segments are Expression::String and
1648+
// expression segments are re-parsed via a sub-Parser.
1649+
// concat_many tolerates int/float args by calling
1650+
// to_string internally — so `f"x={n}"` works for any
1651+
// value type without an explicit to_string call.
1652+
let mut args: Vec<Expression> = Vec::new();
1653+
for part in parts_copy {
1654+
match part {
1655+
FStringPart::Literal(s) => args.push(Expression::String(s)),
1656+
FStringPart::Expr(src) => {
1657+
let mut sub = Parser::new(&src);
1658+
let expr = sub.parse_expression()
1659+
.map_err(|e| format!("f-string expr `{}`: {}", src, e))?;
1660+
args.push(expr);
1661+
}
1662+
}
1663+
}
1664+
// Empty f-string `f""` produces "".
1665+
if args.is_empty() { return Ok(Expression::String(String::new())); }
1666+
Ok(Expression::Call {
1667+
name: "concat_many".to_string(),
1668+
args,
1669+
pos: crate::ast::Pos::unknown(),
1670+
})
1671+
}
15391672
Token::LBracket => self.parse_array(),
15401673
Token::LBrace => self.parse_dict(),
15411674
Token::LParen => {

0 commit comments

Comments
 (0)