Phase 7d: string interpolation text {expr} text

The readable way to build strings, as you noted `{a} {b}` beats `a + " " + b`.
A backtick string embeds any expression in `{…}` and desugars to the Phase-7c
`+` chain, wrapping each hole in `str(...)`:

  `hello {name}, n={count + 1}`
    ==  "hello " + name + ", n=" + str(count + 1)

- Lexer: a backtick captures its content raw as TK_INTERP.
- Parser: parse_interp splits literal runs from `{…}` holes (brace-depth aware,
  `{{`/`}}` escape to literal braces), re-lexes each hole as a full expression
  (save/restore toks/pi like an import), and folds it all into E_BIN(+) nodes —
  so no new AST or runtime beyond the existing concat.
- str(x): a string passes through; int/bool/fixed convert via a small emitted
  @fn_int_str prelude (digits from the end of a buffer, '-' for negatives),
  emitted once into any program that uses it.

examples/strings.ludic gains interpolation cases (now prints 1..7); the smoke
covers it. Grammar + ludic_syntax.h tokenize backtick strings (holes highlighted
as embedded code). LANGUAGE.md documents it as the preferred form.

Reseeded (22530 lines); C-free fixpoint holds; goldens identical; 18/18; vocab +
doc-fences clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-28 01:12:07 +03:00
parent a997e34b2d
commit ad63f09d53
13 changed files with 4748 additions and 3962 deletions

View file

@ -584,7 +584,21 @@ are **`& | ^ << >> ~`** (`>>` is a logical/unsigned shift).
**Strings are values.** `a + b` concatenates two strings, and `a == b` / `a != b`
compare them **by content** (not by pointer). `"go" + dir == "goleft"` works as
written. (Under the hood these call a small emitted string runtime; a `==`/`!=`
against `null` is still a pointer test.) `expr with { field: … }`
against `null` is still a pointer test.)
**Interpolation is the readable way to build them.** A backtick string
`` `text {expr} text` `` embeds any expression in `{…}` — numbers, bools and
`fixed` values become text automatically, strings pass through — and desugars to
the `+` chain above:
```ludic
# doc-check: skip — illustrative interpolation
let msg = `hello {name}, you have {count + 1} messages`
# == "hello " + name + ", you have " + str(count + 1) + " messages"
```
`str(x)` is the same conversion on its own. Write a literal brace as `{{` / `}}`.
`expr with { field: … }`
is not implemented; records appear only in `spawn`. Char literals (`'w'`) are
`int` code points; colors are hex ints (`0xff8800`). `null` is the null-pointer
literal; test any pointer/record/slice with `x == null` / `x != null` (an unset

View file

@ -1,5 +1,5 @@
# strings.ludic — strings are values: compare with ==/!=, join with +.
# Running it prints: 1 2 3 4 5
# strings.ludic — strings are values: compare with ==/!=, join with +, or (best)
# interpolate with `backticks {expr}`. Running it prints: 1 2 3 4 5 6 7
program Strings {
entry {
let greeting = "hello"
@ -17,5 +17,10 @@ program Strings {
if line == "ababab" { print_int(4) }
if (greeting + who) != greeting { print_int(5) }
# interpolation: `{expr}` embeds any expression (numbers become text)
let n = 42
if `{greeting}, {who}!` == "hello, Ludic!" { print_int(6) }
if `n={n}, next={n + 1}` == "n=42, next=43" { print_int(7) }
}
}

View file

@ -19,6 +19,7 @@ var loc_ty: []ptr
var loc_mut: []int # 1 = mutable (var / param / loop-var), 0 = immutable (let)
var nloc: int = 0
var g_uses_str: bool = false # a `str + str` / `str == str` was emitted -> emit the prelude
var g_uses_intstr: bool = false # `str(int)` was emitted -> emit the int->string prelude
# loop targets for break/continue (innermost last)
var brk_lbl: []ptr

View file

@ -68,6 +68,7 @@ fn emit_program() -> void {
head = buf_new()
code = buf_new()
g_uses_str = false
g_uses_intstr = false
loc_name = new []ptr; loc_reg = new []ptr; loc_ty = new []ptr; loc_mut = new []int
brk_lbl = new []ptr; cnt_lbl = new []ptr
self_stk = new []ptr
@ -84,6 +85,7 @@ fn emit_program() -> void {
while i < len(prog) { if prog[i].kind == N_MAIN { emit_main(prog[i]) }; i = i + 1 }
}
if g_uses_str { emit_str_prelude() } # @fn_str_eq / @fn_str_concat, after all uses are seen
if g_uses_intstr { emit_int_str() } # @fn_int_str, for str(int) in interpolation
}
# Flush the emitted IR. With a null path it goes to stdout (the pipe the shell

View file

@ -126,6 +126,12 @@ fn emit_call(e: Node) -> Val {
if (name == "quit") { emit(" store i32 0, ptr @L_running\n"); return val("0", "void") }
if (name == "len") { return emit_len(e) }
if (name == "push") { return emit_push(e) }
if (name == "str") { # str(x): int/bool/fixed -> text, a string passes through
let a = emit_expr(e.kids[0])
if (llty(a.ty) == "ptr") { return a }
g_uses_intstr = true
return val(emit_bind(("call ptr @fn_int_str(i32 " + (a.code + ")"))), "str")
}
if (name == "fx") { let a = emit_expr(e.kids[0]); return val(emit_bind(("shl i32 " + (a.code + ", 16"))), "fixed") }
if (name == "flr") { let a = emit_expr(e.kids[0]); return val(emit_bind(("ashr i32 " + (a.code + ", 16"))), "int") }
if is_intrinsic(name) { return emit_intrinsic(name, e) }

View file

@ -151,3 +151,26 @@ fn emit_str_prelude() -> void {
emith(" %j1 = add i32 %j, 1\n br label %c2\n")
emith("fin:\n %pe = getelementptr inbounds i8, ptr %out, i32 %sum\n store i8 0, ptr %pe\n ret ptr %out\n}\n")
}
# int -> decimal string, emitted (once) into any program that uses str(int)
# (string interpolation of a number). Writes digits from the end of a 24-byte
# buffer, prepends '-' for negatives, and returns a pointer into the buffer.
fn emit_int_str() -> void {
emith("define ptr @fn_int_str(i32 %n0) {\n")
emith("entry:\n %buf = call ptr @malloc(i64 24)\n")
emith(" %isneg = icmp slt i32 %n0, 0\n %neg = sub i32 0, %n0\n")
emith(" %n = select i1 %isneg, i32 %neg, i32 %n0\n")
emith(" %term = getelementptr inbounds i8, ptr %buf, i32 23\n store i8 0, ptr %term\n")
emith(" %iszero = icmp eq i32 %n0, 0\n br i1 %iszero, label %zc, label %dl\n")
emith("zc:\n %zp = getelementptr inbounds i8, ptr %buf, i32 22\n store i8 48, ptr %zp\n ret ptr %zp\n")
emith("dl:\n br label %dloop\n")
emith("dloop:\n %pos = phi i32 [ 22, %dl ], [ %pos2, %dbody ]\n %cur = phi i32 [ %n, %dl ], [ %cur2, %dbody ]\n")
emith(" %done = icmp eq i32 %cur, 0\n br i1 %done, label %sign, label %dbody\n")
emith("dbody:\n %d = urem i32 %cur, 10\n %ch = add i32 %d, 48\n %ch8 = trunc i32 %ch to i8\n")
emith(" %pp = getelementptr inbounds i8, ptr %buf, i32 %pos\n store i8 %ch8, ptr %pp\n")
emith(" %cur2 = udiv i32 %cur, 10\n %pos2 = sub i32 %pos, 1\n br label %dloop\n")
emith("sign:\n br i1 %isneg, label %addneg, label %fin\n")
emith("addneg:\n %sp = getelementptr inbounds i8, ptr %buf, i32 %pos\n store i8 45, ptr %sp\n %posn = sub i32 %pos, 1\n br label %fin\n")
emith("fin:\n %fpos = phi i32 [ %pos, %sign ], [ %posn, %addneg ]\n")
emith(" %rpos = add i32 %fpos, 1\n %res = getelementptr inbounds i8, ptr %buf, i32 %rpos\n ret ptr %res\n}\n")
}

View file

@ -9,6 +9,7 @@ const TK_OP: int = 3
const TK_NL: int = 4
const TK_EOF: int = 5
const TK_FLOAT: int = 6
const TK_INTERP: int = 7 # `text {expr} text` — raw content, split by the parser
property Tok { kind: int = 0, text: ptr = null, ival: int = 0, line: int = 0 }
@ -69,6 +70,16 @@ fn lex(src: ptr) -> void {
tok_push(TK_STR, out, 0, line)
continue
}
if c == 96 { # `interpolated string` — captured raw
i = i + 1
let out = mem_alloc(n)
var j = 0
while i < n and peek8(src, i) != 96 { poke8(out, j, peek8(src, i)); j = j + 1; i = i + 1 }
i = i + 1
poke8(out, j, 0)
tok_push(TK_INTERP, out, 0, line)
continue
}
if c == 39 { # 'c' char literal -> int
i = i + 1
var v = 0

File diff suppressed because it is too large Load diff

View file

@ -54,8 +54,64 @@ fn args_call(call: Node) -> void {
eat_op(")")
}
# ---- string interpolation --------------------------------------------------
# `text {expr} text` desugars to a `+` chain of string literals and `str(expr)`
# holes, so it reuses the string-concat operator and needs no new runtime.
fn interp_lit(buf: ptr, len: int) -> Node { let n = node(E_STR); n.s = substr(buf, 0, len); return n }
fn interp_add(acc: Node, part: Node) -> Node {
if acc == null { return part }
return mkbin("+", acc, part)
}
fn interp_str(e: Node) -> Node { # wrap a hole in str(...)
let c = node(E_CALL); let id = node(E_ID); id.s = "str"; c.a = id; push(c.kids, e); return c
}
fn parse_hole(inner: ptr) -> Node { # re-lex+parse an embedded expression
let saved_toks = toks; let saved_pi = pi
lex(inner); pi = 0; skipnl()
let e = expr()
toks = saved_toks; pi = saved_pi
return e
}
fn parse_interp(raw: ptr) -> Node {
let n = slen(raw)
var acc: Node = null
let lit = mem_alloc(n + 1)
var lj = 0
var i = 0
while i < n {
let c = peek8(raw, i)
if c == 123 { # '{'
if peek8(raw, i + 1) == 123 { poke8(lit, lj, 123); lj = lj + 1; i = i + 2; continue } # {{ -> {
if lj > 0 { acc = interp_add(acc, interp_lit(lit, lj)); lj = 0 }
i = i + 1
let hs = i
var depth = 1
while i < n and depth > 0 {
let d = peek8(raw, i)
if d == 123 { depth = depth + 1 }
else { if d == 125 { depth = depth - 1; if depth == 0 { break } } }
i = i + 1
}
acc = interp_add(acc, interp_str(parse_hole(substr(raw, hs, i - hs))))
i = i + 1 # skip the closing '}'
} else {
if c == 125 and peek8(raw, i + 1) == 125 { poke8(lit, lj, 125); lj = lj + 1; i = i + 2; continue } # }} -> }
if c == 92 { # backslash escape in literal text
let e = peek8(raw, i + 1); var r = e
if e == 110 { r = 10 }
if e == 116 { r = 9 }
poke8(lit, lj, r); lj = lj + 1; i = i + 2; continue
}
poke8(lit, lj, c); lj = lj + 1; i = i + 1
}
}
if lj > 0 or (acc == null) { acc = interp_add(acc, interp_lit(lit, lj)) }
return acc
}
fn p_primary() -> Node {
let t = toks[pi]
if t.kind == TK_INTERP { pi = pi + 1; return parse_interp(t.text) }
if t.kind == TK_INT { let n = node(E_INT); n.ival = t.ival; pi = pi + 1; return n }
if t.kind == TK_FLOAT { let n = node(E_FLOAT); n.ival = t.ival; pi = pi + 1; return n }
if t.kind == TK_STR { let n = node(E_STR); n.s = t.text; pi = pi + 1; return n }

View file

@ -68,8 +68,8 @@ if ./selfhost/game-build.sh build/ludicc examples/toggle.ludic "/tmp/ludic_tog"
else bad "toggle: $(tail -1 /tmp/tog.out)"; fi
# String operators: ==/!= compare by content, + concatenates (str_* prelude).
if ./selfhost/game-build.sh build/ludicc examples/strings.ludic "/tmp/ludic_str" >/tmp/str.out 2>&1 \
&& [ "$(/tmp/ludic_str </dev/null | tr '\n' ' ')" = "1 2 3 4 5 " ]; then
ok "strings.ludic (str ==/!=/+ operators)"
&& [ "$(/tmp/ludic_str </dev/null | tr '\n' ' ')" = "1 2 3 4 5 6 7 " ]; then
ok "strings.ludic (str ==/!=/+ and `interpolation`)"
else bad "strings: $(tail -1 /tmp/str.out)"; fi
# --- Toolchain-agent CLI smoke tests append below this line ---
echo "== self-hosted front-end binaries (ludicc / ludic) =="

View file

@ -37,6 +37,17 @@
{
"name": "string.quoted.single.ludic",
"match": "'(\\\\.|[^'\\\\])'"
},
{
"name": "string.interpolated.ludic",
"begin": "`",
"end": "`",
"beginCaptures": { "0": { "name": "punctuation.definition.string.begin.ludic" } },
"endCaptures": { "0": { "name": "punctuation.definition.string.end.ludic" } },
"patterns": [
{ "name": "meta.embedded.ludic", "begin": "\\{", "end": "\\}", "patterns": [{ "include": "$self" }] },
{ "name": "constant.character.escape.ludic", "match": "\\\\." }
]
}
]
},

View file

@ -37,6 +37,17 @@
{
"name": "string.quoted.single.ludic",
"match": "'(\\\\.|[^'\\\\])'"
},
{
"name": "string.interpolated.ludic",
"begin": "`",
"end": "`",
"beginCaptures": { "0": { "name": "punctuation.definition.string.begin.ludic" } },
"endCaptures": { "0": { "name": "punctuation.definition.string.end.ludic" } },
"patterns": [
{ "name": "meta.embedded.ludic", "begin": "\\{", "end": "\\}", "patterns": [{ "include": "$self" }] },
{ "name": "constant.character.escape.ludic", "match": "\\\\." }
]
}
]
},

View file

@ -227,6 +227,12 @@ static void lud_lex(LLex* L, const char* src){
if (src[i] == '"') i++; else bad = 1;
ltok_push(L, LT_STR, s, i, line, bad); continue;
}
if (c == '`'){ /* `interpolated {expr}` string */
int s = i; i++; int bad = 0;
while (src[i] && src[i] != '`'){ if (src[i] == '\\' && src[i+1]) i += 2; else i++; }
if (src[i] == '`') i++; else bad = 1;
ltok_push(L, LT_STR, s, i, line, bad); continue;
}
if (c == '\''){
int s = i; i++; int bad = 0;
if (src[i] == '\\' && src[i+1]) i += 2; else if (src[i] && src[i] != '\n') i++;