Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions packages/compiler/src/frontend/ts7/program-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,52 @@ function serializeOptions(options: Ts7CompilerOptions): Record<string, unknown>
return out;
}

/** TypeScript's filesystem reader removes a leading UTF-8 BOM before the
* parser sees source text. node:fs's utf8 reader keeps it, so normalize every
* virtual, shadowed, and real host response to the same parser contract. */
function stripSourceBom(text: string): string {
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}

let nextConfigId = 0;

type Ts7TransportDecoder = {
readonly ignoreBOM?: boolean;
decode(input?: Uint8Array, options?: { stream?: boolean }): string;
};

const bomPreservingDecoderPrototypes = new WeakSet<object>();

/** TypeScript 7.0.2's Wtf8Decoder inherits TextDecoder's default BOM
* handling, so decoding an individual AST/checker string-table cell strips
* one leading U+FEFF. Patch that decoder class once after the first project
* exposes an instance: delegate to its WTF-8-aware implementation, then
* restore the one BOM TextDecoder consumed. */
function preserveTransportBoms(project: Project): void {
const decoder = (project.program as unknown as { decoder?: Ts7TransportDecoder }).decoder;
if (decoder === undefined) {
throw new InternalCompilerError("ts7 createProgram: program decoder is unavailable");
}
const prototype = Object.getPrototypeOf(decoder) as Ts7TransportDecoder | null;
if (prototype === null || typeof prototype.decode !== "function") {
throw new InternalCompilerError("ts7 createProgram: program decoder prototype is unavailable");
}
if (bomPreservingDecoderPrototypes.has(prototype)) return;
const decode = prototype.decode;
prototype.decode = function (input, options): string {
const text = decode.call(this, input, options);
return this.ignoreBOM !== true &&
input !== undefined &&
input.length >= 3 &&
input[0] === 0xef &&
input[1] === 0xbb &&
input[2] === 0xbf
? "\uFEFF" + text
: text;
};
bomPreservingDecoderPrototypes.add(prototype);
}

/** One spawned tsgo server plus the virtual-FS overlay serving synthesized
* tsconfigs. Share a host across programs to pay the spawn once; the overlay
* is a live map, so each createProgram call adds its config before taking
Expand Down Expand Up @@ -117,13 +161,14 @@ export class Ts7Host {
// existence; undefined => real-FS fallthrough.
readFile: (fileName) => {
const virtual = virtualFiles.get(tsgoPath(fileName));
if (virtual !== undefined) return virtual;
if (virtual !== undefined) return stripSourceBom(virtual);
if (shadow !== null) {
if (shadow.hideFile(fileName)) return null;
const replacement = shadow.readFile(fileName);
if (replacement !== undefined) return replacement;
if (replacement !== undefined) return stripSourceBom(replacement);
}
return trackedReadFile(fileName);
const source = trackedReadFile(fileName);
return source === null ? null : stripSourceBom(source);
},
fileExists: (fileName) => {
if (virtualFiles.has(tsgoPath(fileName))) return true;
Expand Down Expand Up @@ -172,6 +217,7 @@ export class Ts7Host {
snapshot.dispose();
throw new InternalCompilerError(`ts7 createProgram: project failed to open for ${first}`);
}
preserveTransportBoms(project);
return new Ts7Program(project, snapshot, this, !programOwnsHost);
}

Expand Down
6 changes: 6 additions & 0 deletions packages/compiler/test/ts7/baselines/order-parity.json
Original file line number Diff line number Diff line change
Expand Up @@ -6040,6 +6040,12 @@
],
"diags": []
},
"<repo>/tests/corpus/2854-leading-bom-literals.ts": {
"order": [
"<repo>/tests/corpus/2854-leading-bom-literals.ts"
],
"diags": []
},
"<repo>/tests/corpus/300-if-else.ts": {
"order": [
"<repo>/tests/corpus/300-if-else.ts"
Expand Down
47 changes: 47 additions & 0 deletions packages/compiler/test/ts7/program-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { basename, join } from "node:path";
import { describe, expect, test } from "vitest";
import { checkPreflight, loadProgram } from "../../src/frontend/program.js";
import { tsgoPath } from "../../src/frontend/dts-paths.js";
import * as ts from "../../src/frontend/ts7/adapter.js";
import { CheckerFacade } from "../../src/frontend/ts7/checker.js";
import type { Checker, Project } from "typescript/unstable/sync";

Expand All @@ -21,6 +22,52 @@ describe("tsgo virtual filesystem paths", () => {
});
});

test("preserves leading BOMs in TS7 AST and checker string payloads", () => {
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
const dir = mkdtempSync(join(tempRoot, "scriptc-ts7-bom-"));
const entry = join(dir, "entry.ts");
writeFileSync(entry, [
'const alone = "\\uFEFF";',
'const doubled = "\\uFEFF\\uFEFF";',
"const template = `\\uFEFFvalue`;",
].join("\n"));

const load = loadProgram(entry);
try {
const literals: ts.StringLiteralLike[] = [];
ts.walkPreorder(load.entry, (node) => {
if (ts.isStringLiteralLike(node)) literals.push(node);
});
const expected = ["\uFEFF", "\uFEFF\uFEFF", "\uFEFFvalue"];
expect(literals.map((literal) => literal.text)).toEqual(expected);

const checker = load.program.getTypeChecker();
expect(literals.map((literal) => {
const type = checker.getTypeAtLocation(literal);
return type.isStringLiteralType() ? type.value : null;
})).toEqual(expected);
} finally {
load.dispose();
rmSync(dir, { recursive: true, force: true });
}
});

test("retains source-file BOM stripping", () => {
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
const dir = mkdtempSync(join(tempRoot, "scriptc-ts7-source-bom-"));
const entry = join(dir, "entry.ts");
writeFileSync(entry, "\uFEFFconst value = 1;\n");

const load = loadProgram(entry);
try {
expect(load.entry.statements[0]?.getStart(load.entry)).toBe(0);
expect(load.entry.text).toBe("const value = 1;\n");
} finally {
load.dispose();
rmSync(dir, { recursive: true, force: true });
}
});

test("preflight batches symbols in deferred TDZ-analysis roots", () => {
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
const dir = mkdtempSync(join(tempRoot, "scriptc-preflight-batch-"));
Expand Down
30 changes: 30 additions & 0 deletions tests/corpus/2854-leading-bom-literals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
function codes(value: string): string {
const out: number[] = [];
for (let i = 0; i < value.length; i++) out.push(value.charCodeAt(i));
return out.join(",");
}

function show(label: string, value: string): void {
console.log(`${label} len=${value.length} codes=[${codes(value)}]`);
}

const marker = "\uFEFF";
show("alone", marker);
show("leading", "\uFEFFabc");
show("middle", "a\uFEFFb");
show("doubled", "\uFEFF\uFEFF");
show("template", `\uFEFFabc`);
show("template-head", `\uFEFF${"x"}`);
show("template-tail", `${"x"}\uFEFF`);

function cooked(parts: TemplateStringsArray): string {
return parts[0] ?? "";
}
show("tagged", cooked`\uFEFFtag`);

const keyed = { "\uFEFFkey": 7 };
const key = Object.keys(keyed)[0] ?? "";
console.log(`key len=${key.length} first=${key.charCodeAt(0)} value=${keyed["\uFEFFkey"]}`);

const plain = "hello";
console.log(`detect starts=${plain.startsWith(marker)} index=${plain.indexOf(marker)} parts=${plain.split(marker).length}`);
Loading