mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-26 06:34:20 +08:00
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:
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user