feat: string interpolation via f"..." syntax

Bootstrap compiler:
- Add ekStringInterp AST node with text/expr parts
- Lexer: recognize f"..." prefix for interpolation strings
- Parser: split f"..." into ekStringInterp, parse inner expressions
  via sub-lexer/sub-parser. Supports escaped braces \{ and \}.
- Sema: type-check interpolated expressions, return String type
- HIR lowerer: desugar ekStringInterp to chained String_Concat
  calls with automatic type conversions:
  - int/uint types -> String_FromInt
  - float types -> String_FromFloat
  - bool -> String_FromBool
  - String -> as-is

Selfhost compiler:
- ast.bux: add ekStringInterp constant (reserved for future)
- lexer.bux: accept f" prefix, treat as plain string literal
  (selfhost parser does not implement interpolation yet)

Tests:
- _test_string_interp verifies int, float, bool, String,
  multiple interpolations, plain strings, and Fmt-style {0} templates
This commit is contained in:
2026-06-08 21:48:10 +03:00
parent cfce2a93e5
commit 17c896c4dc
7 changed files with 135 additions and 4 deletions
+7
View File
@@ -127,6 +127,7 @@ type
ekBorrow ## borrow &mut expr — explicit borrow expression ekBorrow ## borrow &mut expr — explicit borrow expression
ekBlock ekBlock
ekMatch ekMatch
ekStringInterp
MatchArm* = object MatchArm* = object
loc*: SourceLocation loc*: SourceLocation
@@ -219,6 +220,9 @@ type
of ekMatch: of ekMatch:
exprMatchSubject*: Expr exprMatchSubject*: Expr
exprMatchArms*: seq[MatchArm] exprMatchArms*: seq[MatchArm]
of ekStringInterp:
exprInterpTexts*: seq[string]
exprInterpExprs*: seq[Expr]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Statements # Statements
@@ -458,3 +462,6 @@ proc newBinaryExpr*(op: TokenKind, left, right: Expr, loc: SourceLocation): Expr
proc newUnaryExpr*(op: TokenKind, operand: Expr, loc: SourceLocation): Expr = proc newUnaryExpr*(op: TokenKind, operand: Expr, loc: SourceLocation): Expr =
result = Expr(kind: ekUnary, loc: loc, exprUnaryOp: op, exprUnaryOperand: operand) result = Expr(kind: ekUnary, loc: loc, exprUnaryOp: op, exprUnaryOperand: operand)
proc newStringInterpExpr*(texts: seq[string], exprs: seq[Expr], loc: SourceLocation): Expr =
result = Expr(kind: ekStringInterp, loc: loc, exprInterpTexts: texts, exprInterpExprs: exprs)
+41
View File
@@ -1092,6 +1092,47 @@ proc lowerExpr(ctx: var LowerCtx, expr: Expr): HirNode =
# The borrow checker validates before lowering # The borrow checker validates before lowering
return ctx.lowerExpr(expr.exprBorrowOperand) return ctx.lowerExpr(expr.exprBorrowOperand)
of ekStringInterp:
# Desugar string interpolation to chained String_Concat calls with conversions
var resultNode: HirNode = nil
for i in 0 ..< expr.exprInterpExprs.len:
let textPart = expr.exprInterpTexts[i]
let exprPart = expr.exprInterpExprs[i]
# Text literal
var textNode = HirNode(kind: hLit,
litToken: Token(kind: tkStringLiteral, text: "\"" & textPart & "\"", loc: loc),
typ: makeStr(), loc: loc)
if resultNode == nil:
resultNode = textNode
else:
resultNode = hirCall("String_Concat", @[resultNode, textNode], makeStr(), loc)
# Expression part with conversion if needed
let loweredExpr = ctx.lowerExpr(exprPart)
let exprType = ctx.resolveExprType(exprPart)
var convertedExpr = loweredExpr
if exprType.kind == tkInt or exprType.kind == tkInt8 or exprType.kind == tkInt16 or
exprType.kind == tkInt32 or exprType.kind == tkInt64 or
exprType.kind == tkUInt or exprType.kind == tkUInt8 or exprType.kind == tkUInt16 or
exprType.kind == tkUInt32 or exprType.kind == tkUInt64:
convertedExpr = hirCall("String_FromInt", @[loweredExpr], makeStr(), loc)
elif exprType.kind == tkFloat32 or exprType.kind == tkFloat64:
convertedExpr = hirCall("String_FromFloat", @[loweredExpr], makeStr(), loc)
elif exprType.kind == tkBool:
convertedExpr = hirCall("String_FromBool", @[loweredExpr], makeStr(), loc)
elif exprType.kind == tkStr:
discard # already a string
resultNode = hirCall("String_Concat", @[resultNode, convertedExpr], makeStr(), loc)
# Add final text part
let lastText = expr.exprInterpTexts[^1]
var lastTextNode = HirNode(kind: hLit,
litToken: Token(kind: tkStringLiteral, text: "\"" & lastText & "\"", loc: loc),
typ: makeStr(), loc: loc)
if resultNode == nil:
resultNode = lastTextNode
else:
resultNode = hirCall("String_Concat", @[resultNode, lastTextNode], makeStr(), loc)
return resultNode
else: else:
return HirNode(kind: hLit, litToken: Token(kind: tkIntLiteral, text: "0", loc: loc), return HirNode(kind: hLit, litToken: Token(kind: tkIntLiteral, text: "0", loc: loc),
typ: makeVoid(), loc: loc) typ: makeVoid(), loc: loc)
+4 -1
View File
@@ -527,7 +527,10 @@ proc nextToken(lex: var Lexer): Token =
discard lex.advance() discard lex.advance()
return Token(kind: tkNewLine, text: "\n", loc: startLoc) return Token(kind: tkNewLine, text: "\n", loc: startLoc)
# String prefixes: c8" c16" c32" — must come before ident check # String prefixes: f" c8" c16" c32" — must come before ident check
if c == 'f' and lex.peek(1) == '"':
discard lex.advance() # f
return lex.scanString(startLoc, 1)
if c == 'c' and lex.peek(1) in {'8', '1', '3'}: if c == 'c' and lex.peek(1) in {'8', '1', '3'}:
let d = lex.peek(1) let d = lex.peek(1)
if d == '8' and lex.peek(2) == '"': if d == '8' and lex.peek(2) == '"':
+72 -2
View File
@@ -1,5 +1,5 @@
import std/strutils import std/strutils
import token, source_location, ast import token, source_location, ast, lexer
type type
ParserDiagnosticSeverity* = enum ParserDiagnosticSeverity* = enum
@@ -48,6 +48,12 @@ proc advance(p: var Parser): Token =
if p.pos < p.tokens.len: if p.pos < p.tokens.len:
inc p.pos inc p.pos
proc hasErrors*(p: Parser): bool =
for d in p.diagnostics:
if d.severity == pdsError:
return true
return false
proc check(p: Parser, kind: TokenKind): bool = proc check(p: Parser, kind: TokenKind): bool =
p.peek() == kind p.peek() == kind
@@ -391,13 +397,77 @@ proc parseAssign(p: var Parser): Expr
proc parseExpr(p: var Parser): Expr = proc parseExpr(p: var Parser): Expr =
p.parseAssign() p.parseAssign()
proc parseStringInterpolation(p: var Parser, tok: Token): Expr =
## Parse a string literal that contains {expr} interpolations.
let text = tok.text
# Only f"..." strings support interpolation; plain "...", backtick, and c8/c16/c32 are raw.
if text.len < 3 or text[0] != 'f' or text[1] != '"' or text[^1] != '"':
return newLiteralExpr(tok)
let inner = text[2 ..< ^1] # strip f" ... "
var texts: seq[string] = @[]
var exprs: seq[Expr] = @[]
var i = 0
var currentText = ""
while i < inner.len:
if inner[i] == '\\' and i + 1 < inner.len:
if inner[i + 1] == '{':
currentText.add('{')
i += 2
continue
elif inner[i + 1] == '}':
currentText.add('}')
i += 2
continue
elif inner[i] == '{':
texts.add(currentText)
currentText = ""
var j = i + 1
var depth = 1
while j < inner.len and depth > 0:
if inner[j] == '{': depth += 1
elif inner[j] == '}': depth -= 1
j += 1
if depth != 0:
p.diagnostics.add(ParserDiagnostic(severity: pdsError, loc: tok.loc,
message: "unmatched '{' in string interpolation"))
return newLiteralExpr(tok)
let exprStr = inner[i + 1 ..< j - 1]
if exprStr.len == 0:
p.diagnostics.add(ParserDiagnostic(severity: pdsError, loc: tok.loc,
message: "empty interpolation {}"))
return newLiteralExpr(tok)
let lexRes = tokenize(exprStr, p.sourceName & "<interp>")
if lexRes.hasErrors:
p.diagnostics.add(ParserDiagnostic(severity: pdsError, loc: tok.loc,
message: "invalid expression in string interpolation: " & exprStr))
return newLiteralExpr(tok)
var subParser = initParser(lexRes.tokens, p.sourceName & "<interp>")
let expr = subParser.parseExpr()
if subParser.hasErrors:
for d in subParser.diagnostics:
p.diagnostics.add(ParserDiagnostic(severity: pdsError, loc: tok.loc,
message: "interpolation parse error: " & d.message))
return newLiteralExpr(tok)
exprs.add(expr)
i = j
continue
currentText.add(inner[i])
i += 1
texts.add(currentText)
if exprs.len == 0:
return newLiteralExpr(tok)
return newStringInterpExpr(texts, exprs, tok.loc)
proc parsePrimary(p: var Parser): Expr = proc parsePrimary(p: var Parser): Expr =
while p.check(tkNewLine): while p.check(tkNewLine):
discard p.advance() discard p.advance()
let loc = p.currentLoc let loc = p.currentLoc
case p.peek() case p.peek()
of tkIntLiteral, tkFloatLiteral, tkStringLiteral, tkCharLiteral, tkBoolLiteral: of tkIntLiteral, tkFloatLiteral, tkStringLiteral, tkCharLiteral, tkBoolLiteral:
return newLiteralExpr(p.advance()) let tok = p.advance()
if tok.kind == tkStringLiteral:
return parseStringInterpolation(p, tok)
return newLiteralExpr(tok)
of tkSelf: of tkSelf:
discard p.advance() discard p.advance()
return Expr(kind: ekSelf, loc: loc) return Expr(kind: ekSelf, loc: loc)
+4
View File
@@ -1264,6 +1264,10 @@ proc checkExpr(sema: var Sema, expr: Expr, scope: Scope): Type =
return operand return operand
of ekSpread: of ekSpread:
return sema.checkExpr(expr.exprSpreadOperand, scope) return sema.checkExpr(expr.exprSpreadOperand, scope)
of ekStringInterp:
for e in expr.exprInterpExprs:
discard sema.checkExpr(e, scope)
return makeStr()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Statement type checking # Statement type checking
+1
View File
@@ -95,6 +95,7 @@ const ekBlock: int = 21;
const ekMatch: int = 22; const ekMatch: int = 22;
const ekSpawn: int = 24; const ekSpawn: int = 24;
const ekAwait: int = 25; const ekAwait: int = 25;
const ekStringInterp: int = 26;
struct ExprList { struct ExprList {
expr: *Expr, expr: *Expr,
+6 -1
View File
@@ -704,7 +704,12 @@ func lexNextToken(lex: *Lexer) {
return; return;
} }
// String prefixes: c8" c16" c32" // String prefixes: f" c8" c16" c32"
if c == 102 && lexPeek(lex, 1) == 34 { // f"
discard lexAdvance(lex); // f
lexMarkStart(lex); // treat as plain string literal in selfhost
lexScanString(lex); return;
}
if c == 99 { // 'c' if c == 99 { // 'c'
let d: uint32 = lexPeek(lex, 1); let d: uint32 = lexPeek(lex, 1);
if d == 56 && lexPeek(lex, 2) == 34 { // c8" if d == 56 && lexPeek(lex, 2) == 34 { // c8"