diff --git a/packages/compiler/src/backend/llvm/expr-async.test.ts b/packages/compiler/src/backend/llvm/expr-async.test.ts new file mode 100644 index 000000000..4f05d3fb9 --- /dev/null +++ b/packages/compiler/src/backend/llvm/expr-async.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "vitest"; +import { BOOL, F64, STRING, VOID, type IrExpr, type IrModule } from "../../ir/ir.js"; +import { validateModule } from "../../ir/validate.js"; +import { emitLlvmModule } from "./emitter.js"; + +const loc = { file: "console.ts", start: 0, end: 1 }; +const text = (value: string): IrExpr => ({ kind: "strLit", value, type: STRING, loc }); +const ref = (localId: string, type = STRING): IrExpr => ({ kind: "varRef", localId, type, loc }); +const str = (operand: IrExpr): IrExpr => ({ kind: "toString", operand, type: STRING, loc }); +const concat = (left: IrExpr, right: IrExpr): IrExpr => ({ + kind: "strConcat", + left, + right, + type: STRING, + loc, +}); + +function logBody(name: "console.log" | "console.error", args: IrExpr[]): string { + const mod: IrModule = { + irVersion: 15, + sourceFile: loc.file, + entry: "main", + functions: [ + { name: "main", loc, params: [], locals: [], returnType: VOID, body: [] }, + { + name: "report", + loc, + params: [ + { name: "label", localId: "label", type: STRING }, + { name: "n", localId: "n", type: F64 }, + { name: "ok", localId: "ok", type: BOOL }, + ], + locals: [ + { id: "label", name: "label", type: STRING, mutable: false }, + { id: "n", name: "n", type: F64, mutable: false }, + { id: "ok", name: "ok", type: BOOL, mutable: false }, + ], + returnType: VOID, + body: [{ kind: "exprStmt", expr: { kind: "intrinsic", name, args, type: VOID, loc }, loc }], + }, + ], + }; + expect(validateModule(mod)).toEqual([]); + return /^define internal [^\n]*@sc_bf_report\([^]*?^}/m.exec(emitLlvmModule(mod))![0]; +} + +test("a template-literal argument passes its parts without joining them", () => { + // console.log(`${label} n=${n} ok=${ok}`, n) + const template = concat( + concat(concat(concat(ref("label"), text(" n=")), str(ref("n", F64))), text(" ok=")), + str(ref("ok", BOOL)), + ); + const body = logBody("console.log", [template, ref("n", F64)]); + expect(body).toContain("call void @scr_console_log_parts(i64 6, ptr %logargs)"); + expect(body).not.toContain("@scr_str_concat"); + expect(body).not.toContain("@scr_f64_to_scrstr"); + expect(body).not.toContain("@scr_bool_to_scrstr"); + // Tags: label (string), " n=" (glued string), n (glued String() number), + // " ok=" (glued string), ok (glued bool), then n as its own argument. + const tags = [...body.matchAll(/store i32 (\d+), ptr/g)].map((m) => Number(m[1])); + expect(tags).toEqual([1, 0x101, 0x103, 0x101, 0x102, 0]); +}); + +test("plain arguments keep the original entry points", () => { + const body = logBody("console.error", [ref("label"), ref("n", F64), ref("ok", BOOL)]); + expect(body).toContain("call void @scr_console_error(i64 3, ptr %logargs)"); + expect(body).not.toContain("_parts"); +}); diff --git a/packages/compiler/src/backend/llvm/expr-async.ts b/packages/compiler/src/backend/llvm/expr-async.ts index b3e99fd1d..d962ab007 100644 --- a/packages/compiler/src/backend/llvm/expr-async.ts +++ b/packages/compiler/src/backend/llvm/expr-async.ts @@ -1,7 +1,9 @@ import { emitBorrowedInput } from "./borrowed-inputs.js"; /* Focused LLVM expression emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; -import { type IrType, isRefCounted, isUnitType, typeEquals } from "../../ir/ir.js"; +import { type IrExpr, type IrType, isRefCounted, isUnitType, typeEquals } from "../../ir/ir.js"; +import { stringParts } from "./string-construction.js"; +import { emitStringInputs } from "./string-lifetimes.js"; import { mangleRecordNew } from "../mangle.js"; import { arrNewCall, traceAdapter, traceArg, vAdapters } from "./shapes.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -174,12 +176,43 @@ export function emitIntrinsicExpr(host: LlvmEmitterContext, e: ExprOf<"intrinsic // function's max arity), tag + 8-byte union slot per argument. // String args are BORROWED — their temps stay frame-owned and // release at statement end, after the call. - const args = e.args.map((a) => host.emitExpr(a)); + // + // A string-concatenation argument (template literal, `"a" + n`) + // passes its parts instead of the joined string: later parts carry + // the GLUE flag (no separating space), and number/boolean conversions + // pass the raw value (SCR_ARG_NUM keeps String()'s "-0" -> "0"). The + // runtime renders straight into the line it writes, so the parts' + // strings and the joined copy are never allocated. Parts evaluate in + // source order with concatenation's borrowing rules. + const plan: { expr: IrExpr; glue: boolean; num: boolean }[] = []; + for (const a of e.args) { + if (a.kind !== "strConcat") { + plan.push({ expr: a, glue: false, num: false }); + continue; + } + stringParts(a).forEach((part, j) => { + const operand = part.kind === "toString" ? part.operand : undefined; + if ( + operand !== undefined && + (operand.type.kind === "f64" || operand.type.kind === "bool") + ) + plan.push({ expr: operand, glue: j > 0, num: operand.type.kind === "f64" }); + else plan.push({ expr: part, glue: j > 0, num: false }); + }); + } + const usesParts = plan.length !== e.args.length; + const args = usesParts + ? emitStringInputs( + host, + plan.map((p) => p.expr), + ) + : e.args.map((a) => host.emitExpr(a)); host.logArgSlots = Math.max(host.logArgSlots, Math.max(args.length, 1)); args.forEach((a, i) => { const tagOf: Record = { f64: 0, string: 1, bool: 2 }; - const tag = tagOf[a.type.kind]; - if (tag === undefined) throw new LlvmUnsupportedError(`logArg:${a.type.kind}`, e.loc); + const kind = usesParts && plan[i]!.num ? 3 : tagOf[a.type.kind]; + if (kind === undefined) throw new LlvmUnsupportedError(`logArg:${a.type.kind}`, e.loc); + const tag = usesParts && plan[i]!.glue ? kind | 0x100 : kind; const tp = B.tmp(); const vp = B.tmp(); B.line(`${tp} = getelementptr inbounds %ScrLogArg, ptr %logargs, i64 ${i}, i32 0`); @@ -193,7 +226,9 @@ export function emitIntrinsicExpr(host: LlvmEmitterContext, e: ExprOf<"intrinsic B.line(`store i8 ${z}, ptr ${vp}`); } }); - const fn = e.name === "console.error" ? "scr_console_error" : "scr_console_log"; + const fn = + (e.name === "console.error" ? "scr_console_error" : "scr_console_log") + + (usesParts ? "_parts" : ""); host.declare(`declare void @${fn}(${host.sizeType}, ptr)`); B.line(`call void @${fn}(${host.sizeType} ${args.length}, ptr %logargs)`); return { name: "", type: e.type }; diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index b48d20b81..64c1a6952 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -261,19 +261,21 @@ static size_t scr_console_capacity(size_t n, const ScrLogArg *args) { static size_t scr_console_render(char *line, size_t n, const ScrLogArg *args) { size_t used = 0; for (size_t i = 0; i < n; i++) { - if (i) line[used++] = ' '; const ScrLogArg *arg = &args[i]; - if (arg->tag == SCR_ARG_STR) { + if (i && !(arg->tag & SCR_ARG_GLUE)) line[used++] = ' '; + int kind = arg->tag & SCR_ARG_KIND; + if (kind == SCR_ARG_STR) { memcpy(line + used, arg->v.s->data, arg->v.s->len); used += arg->v.s->len; - } else if (arg->tag == SCR_ARG_BOOL) { + } else if (kind == SCR_ARG_BOOL) { const char *text = arg->v.b ? "true" : "false"; size_t length = arg->v.b ? 4 : 5; memcpy(line + used, text, length); used += length; - } else if (arg->v.f == 0 && signbit(arg->v.f)) { + } else if (kind == SCR_ARG_F64 && arg->v.f == 0 && signbit(arg->v.f)) { /* console.log renders numbers via inspect, which distinguishes -0 - * (String(-0) is "0", but console.log(-0) prints "-0"). */ + * (String(-0) is "0", but console.log(-0) prints "-0"). A number + * inside a concatenation (SCR_ARG_NUM) keeps String()'s "0". */ memcpy(line + used, "-0", 2); used += 2; } else { @@ -328,6 +330,14 @@ void scr_console_error(size_t n, const ScrLogArg *args) { scr_console_write(2, n, args); } +void scr_console_log_parts(size_t n, const ScrLogArg *args) { + scr_console_write(1, n, args); +} + +void scr_console_error_parts(size_t n, const ScrLogArg *args) { + scr_console_write(2, n, args); +} + /* One JavaScript-visible raw write. Node's global console and process stream * methods hand each chunk to the backing stream when called; they do not * retain stdout until a later stderr write, a 64 KiB threshold, or process diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 48ab0be78..789c507be 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -8006,8 +8006,19 @@ void scr_assert_eq_sym(ScrSym *a, ScrSym *b, bool negated, bool deep, /* ── console ──────────────────────────────────────────────────────────── */ +enum { + SCR_ARG_F64, /* a number argument: inspect rendering (-0 prints "-0") */ + SCR_ARG_STR, /* a string, verbatim */ + SCR_ARG_BOOL, /* true / false */ + SCR_ARG_NUM, /* a number inside a string concatenation: String() rendering + * (-0 prints "0") — scr_console_*_parts only */ + SCR_ARG_KIND = 0xff, + SCR_ARG_GLUE = 0x100, /* flag: this part continues the previous argument + * (no separating space) — scr_console_*_parts only */ +}; + typedef struct { - enum { SCR_ARG_F64, SCR_ARG_STR, SCR_ARG_BOOL } tag; + int tag; /* SCR_ARG_KIND bits, optionally | SCR_ARG_GLUE */ union { double f; ScrStr *s; @@ -8021,6 +8032,13 @@ void scr_console_log(size_t n, const ScrLogArg *args); * identical formatting, with stdout settled first so merged (2>&1) output * keeps source order. */ void scr_console_error(size_t n, const ScrLogArg *args); +/* The same lines where the compiler passed a string-concatenation argument + * (template literal, `"a" + n`) as its parts — glued with SCR_ARG_GLUE and + * numbers as SCR_ARG_NUM — so the joined string is never materialized. + * Separate entry points: programs using these tags cannot link against a + * runtime that does not understand them. */ +void scr_console_log_parts(size_t n, const ScrLogArg *args); +void scr_console_error_parts(size_t n, const ScrLogArg *args); bool scr_fetch_web_is(const ScrDyn *value, const ScrStr *name); bool scr_fetch_stream_is(const ScrDyn *value); diff --git a/tests/corpus/4295-console-template-parts.ts b/tests/corpus/4295-console-template-parts.ts new file mode 100644 index 000000000..2cc83d048 --- /dev/null +++ b/tests/corpus/4295-console-template-parts.ts @@ -0,0 +1,60 @@ +// console.log/console.error whose arguments are string concatenations +// (template literals, `+` chains). The compiler passes the parts to the +// runtime instead of the joined string; output must equal printing the +// joined string: String() number spelling inside the text (-0 is "0", unlike +// a bare -0 argument), no extra spaces between parts, one space between +// arguments, source-order evaluation, and stable string lifetimes. +const negZero = -0; +const values = [0, negZero, 1, -1, 0.5, 1e21, 1e-7, 2 ** 53, NaN, Infinity, -Infinity, 123.456]; +for (const v of values) { + console.log(`v=${v}`, v, "" + v); + console.error(`[${v}]` + "|" + v); +} + +const flag = values.length > 3; +console.log(`flag ${flag} ${!flag}`, flag); +console.log("plain " + 1 + 2, 1 + 2 + " sum"); +console.log(`${""}${""}`, `${"a"}${""}${"b"}`, "", `x`); +console.log(`unicode caf\u00e9 ${"\u{1f9e8}"} ${values.length}\u2014ok`); + +// More than sixteen parts: the tail stays a grouped concatenation. +const n = 7; +console.log( + `${n}a${n}b${n}c${n}d${n}e${n}f${n}g${n}h${n}i${n}j${n}k${n}l${n}m${n}n${n}o${n}p${n}q${n}r${n}`, +); + +// Parts evaluate in source order, once each. +let calls = ""; +function tick(label: string, value: number): number { + calls += label; + return value; +} +console.log(`${tick("a", 1)}-${tick("b", 2)}`, tick("c", 3), `${tick("d", 4)}`); +console.log(calls); + +// A later part reassigns a string used by an earlier part. +let s = "first-" + String(n); +function reassign(): string { + s = "second-" + String(n); + return "r"; +} +console.log(`${s}:${reassign()}:${s}`); +console.error(`${s}/${(s = "third")}/${s}`); + +// Optional and union-typed values inside templates. +const maybe: string | undefined = n > 100 ? "never" : undefined; +const either: string | number = n > 3 ? n * 2 : "small"; +console.log(`maybe=${maybe} either=${either}`, maybe === undefined); + +// Inside functions, loops, and async code. +function line(id: number, ok: boolean): void { + if (ok) console.log(`#${id} ok latency=${id * 1.5}ms`); + else console.error("#" + id + " failed after " + id * 0.25 + "s", id); +} +for (let i = 0; i < 4; i++) line(i, i % 2 === 0); + +async function later(): Promise { + await Promise.resolve(); + console.log(`async ${values.length} ${negZero}`, negZero); +} +later();