Pass string-concatenation console arguments as parts

console.log(`${method} ${path} ${status}`) used to build the joined string
first (one string per number, scr_str_concat_parts, then releases), and
the console then copied that string into the line it writes. The LLVM
backend now flattens a strConcat argument with stringParts and passes
each part as a ScrLogArg:

- Later parts carry SCR_ARG_GLUE, so no separating space is added.
- A number conversion passes the raw double as SCR_ARG_NUM (String()
  spelling, so -0 prints "0"; a bare number argument keeps inspect's
  "-0").
- A boolean conversion passes the bool.

The runtime renders the parts directly into the line buffer. Parts are
evaluated in source order through emitStringInputs, which applies the
same borrowing rules as concatenation. Calls that use the new tags go to
new scr_console_log_parts / scr_console_error_parts entry points, so a
program object linked against an older runtime pack fails at link time
instead of misprinting.

Tests: corpus 4295 covers -0/NaN/exponent spellings inside templates
next to bare arguments, empty parts, more than 16 parts, evaluation
order, a later part reassigning an earlier string, union and optional
values, and async code. A new co-located IR unit test checks the
emitted tags and entry points.

Linux sandbox, ns per line (median of 9 launches, 200k lines), prev =
stack tip, cand = this commit (with the previous one). All outputs
byte-identical to Node.

  probe      target  node   prev  cand
  p-str      pipe    4022   1364  1149
  p-str      file    3376    849   744
  p-str      null    2360    330   247
  p-args     pipe    6687   1441  1343
  p-args     file    5135    969   821
  p-args     null    3831    389   292
  p-write    pipe    2866   1244  1168
  p-write    file    2329    839   782
  p-write    null    1622    309   275
  p-err      pipe    4656   3253  1073
  p-err      file    3659   2099   721
  p-err      null    2749    591   219
  log-lines  pipe    5007   1476  1314
  log-lines  file    3949    965   877
  log-lines  null    3145    446   378
This commit is contained in:
Malte Ubl
2026-10-08 15:51:42 -05:00
committed by GitHub
parent c48f059071
commit 0cd28f7d45
5 changed files with 202 additions and 11 deletions
@@ -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");
});
@@ -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<string, number> = { 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 };
+15 -5
View File
@@ -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
+19 -1
View File
@@ -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);
@@ -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<void> {
await Promise.resolve();
console.log(`async ${values.length} ${negZero}`, negZero);
}
later();