Skip to content
Open
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
200 changes: 183 additions & 17 deletions Herebyfile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ const enumDefs = [
/**
* @param {string} block
* @param {EnumDef} def
* @returns {{ name: string, value: string }[]}
* @returns {EnumMember[]}
*/
function parseGoConstBlock(block, def) {
const prefix = def.goPrefix;
Expand Down Expand Up @@ -417,9 +417,16 @@ function parseGoStringValue(goValue, replacements) {
return JSON.stringify(result);
}

/**
* @typedef {{
* name: string;
* value: string;
* }} EnumMember
*/

/**
* @param {EnumDef} def
* @returns {{ name: string, value: string }[]}
* @returns {EnumMember[]}
*/
function parseGoEnum(def) {
const source = fs.readFileSync(def.goFile, "utf-8");
Expand All @@ -436,8 +443,8 @@ function parseGoEnum(def) {
/**
* Topologically sort enum members so composite members appear after
* all members they reference (Go allows forward references, TS does not).
* @param {{ name: string, value: string }[]} members
* @returns {{ name: string, value: string }[]}
* @param {EnumMember[]} members
* @returns {EnumMember[]}
*/
function topoSortMembers(members) {
const nameSet = new Set(members.map(m => m.name));
Expand All @@ -453,7 +460,7 @@ function topoSortMembers(members) {
deps.set(m.name, refs);
}

const sorted = /** @type {{ name: string, value: string }[]} */ ([]);
const sorted = /** @type {EnumMember[]} */ ([]);
const visited = new Set();
const visiting = new Set();

Expand All @@ -467,7 +474,7 @@ function topoSortMembers(members) {
}
visiting.delete(name);
visited.add(name);
sorted.push(/** @type {{ name: string, value: string }} */ (members.find(m => m.name === name)));
sorted.push(/** @type {EnumMember} */ (members.find(m => m.name === name)));
}

for (const m of members) {
Expand All @@ -478,7 +485,7 @@ function topoSortMembers(members) {

/**
* @param {EnumDef} def
* @param {{ name: string, value: string }[]} members
* @param {EnumMember[]} members
* @returns {string}
*/
function renderEnumTS(def, members) {
Expand All @@ -488,6 +495,126 @@ function renderEnumTS(def, members) {
return `${header}export enum ${def.name} {\n${lines.join("\n")}\n}\n`;
}

const enumValuesGeneratedGoPath = "tsc/internal/api/enum_values_generated.go";

/**
* @typedef {{
* def: EnumDef
* code: string,
* fileNames: string[]
* members: EnumMember[]
* }} GeneratedEnum
*/

/**
* Ask the Go compiler what it actually thinks each numeric member's value is, so that
* generated TS values can be checked against Go's own arithmetic rather than trusting that
* copying operator-by-operator text from Go into JS preserves precedence/semantics.
*
* Writes tsc/internal/api/enum_values_generated.go, a standalone program that imports every
* package referenced by enumDefs and references each member by its original Go identifier
* (not by re-deriving it from the parsed TS text), so Go itself — not this script — computes
* the ground-truth value, then prints them as JSON. `internal/api` is used as the host package
* because it already imports (nearly) every package enums are sourced from.
*
* @param {GeneratedEnum[]} generatedEnums
* @returns {Promise<Record<string, Record<string, number>>>} enum def name -> (memberName -> Go value)
*/
async function computeGoGroundTruth(generatedEnums) {
/** @type {Map<string, {importPath: string, pkgName: string}>} */
const packagesByDir = new Map();
/**
* @param {EnumDef} def
* @returns {string}
*/
function getPackageName(def) {
const dir = path.dirname(def.goFile);
const importPath = `github.com/microsoft/TypeScript/tsc/${dir.replace(/^tsc[\\/]/, "")}`.replace(/\\/g, "/");
let info = packagesByDir.get(dir);
if (info === undefined) {
info = { importPath, pkgName: path.basename(dir) };
packagesByDir.set(dir, info);
}
return info.pkgName;
}

/** @type {string[]} */
const entries = [];
for (const { def, members } of generatedEnums) {
if (def.stringEnum) continue;
const pkgName = getPackageName(def);

/** @type {string[]} */
const memberEntries = members.map(m => {
return `\t\t\t${JSON.stringify(m.name)}: toInt32(${pkgName}.${def.goPrefix}${m.name}),`;
});
entries.push(
`\t\t${JSON.stringify(def.name)}: {\n${memberEntries.join("\n")}\n\t\t},`,
);
}

const importLines = [...packagesByDir.values()]
.sort((a, b) => a.importPath.localeCompare(b.importPath))
.map(({ pkgName, importPath }) => `\t${pkgName} "${importPath}"`);

const goSource = `//go:build ignore

// Code generated by Herebyfile.mjs generate:enums. DO NOT EDIT.
// Running this program prints the real Go-evaluated value of every generated enum member as
// JSON, so generate:enums can validate that the TypeScript it emits agrees with Go's own
// arithmetic (catching, e.g., operator-precedence mistakes introduced by copying Go expression
// text into TypeScript verbatim).

package main

import (
\t"encoding/json"
\t"os"

${importLines.join("\n")}
)

func main() {
\tvalues := map[string]map[string]int32{
${entries.join("\n")}
\t}
\tif err := json.NewEncoder(os.Stdout).Encode(values); err != nil {
\t\tpanic(err)
\t}
}

// A generic function call (unlike a constant conversion) forces Go to evaluate the conversion at
// runtime, truncating uint32-backed flags with a leading bitwise-not the same way JS's 32-bit
// bitwise operators would, instead of rejecting "constant overflows int32" at compile time.
func toInt32[T ~int8 | ~int16 | ~int32 | ~int | ~uint8 | ~uint16 | ~uint32](v T) int32 {
\treturn int32(v)
}

`;

fs.writeFileSync(enumValuesGeneratedGoPath, goSource);
await $`dprint fmt ${enumValuesGeneratedGoPath}`;

const { stdout } = await $pipe`go run ${enumValuesGeneratedGoPath}`;
/** @type {Record<string, Record<string, number>>} */
const parsed = JSON.parse(stdout);
return parsed;
}

/**
* Evaluate the generated IIFE in a sandbox and return each member's actual runtime value, so
* validation checks what TS really computes rather than re-deriving it from the source text.
* @param {string} enumSource
* @param {string} enumName
* @returns {Promise<Record<string, number | string>>}
*/
async function evaluateEnumMembers(enumSource, enumName) {
const enumModule = await import(`data:text/javascript;charset=utf-8,${encodeURIComponent(enumSource)}`);
/** @type {Record<string, number | string>} */
const enumObj = enumModule[enumName];
return enumObj;
}

async function runGenerateEnums() {
const ts = /** @type {typeof import("typescript")} */ (await import("typescript"));

Expand All @@ -497,22 +624,28 @@ async function runGenerateEnums() {
* @returns {string}
*/
function transpile(enumSource, enumName) {
const result = ts.transpileModule(enumSource, {
return ts.transpileModule(enumSource, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ESNext,
},
});
return result.outputText.replace(
}).outputText;
}
/**
* @param {string} enumSource
* @param {string} enumName
* @returns {string}
*/
function convertEnumToTs(enumSource, enumName) {
return enumSource.replace(
`export var ${enumName};`,
`export var ${enumName}: any;`,
);
}

console.log("Generating enums from Go source...");
/** @type {string[]} */
const generatedFiles = [];

/** @type {Array<GeneratedEnum>} */
const generatedEnums = [];
for (const def of enumDefs) {
const members = parseGoEnum(def);
const camelName = def.name.charAt(0).toLowerCase() + def.name.slice(1);
Expand All @@ -523,18 +656,51 @@ async function runGenerateEnums() {
const enumTS = renderEnumTS(def, members);
const enumPath = path.join(def.outDir, `${camelName}.enum.ts`);
fs.writeFileSync(enumPath, enumTS);
generatedFiles.push(enumPath);

// Generate .ts (IIFE — used at runtime)
const iifeSource = transpile(enumTS, def.name);
const enumJsCode = transpile(enumTS, def.name);
const iifeSource = convertEnumToTs(enumJsCode, def.name);
const iifePath = path.join(def.outDir, `${camelName}.ts`);
fs.writeFileSync(iifePath, iifeSource);
generatedFiles.push(iifePath);
generatedEnums.push({
code: enumJsCode,
def,
members,
fileNames: [enumPath, iifePath],
});

console.log(` ${def.name}: ${members.length} members → ${camelName}.enum.ts, ${camelName}.ts`);
}

await $`dprint fmt ${generatedFiles}`;
console.log("Getting values from go");
const goValuesByEnum = await computeGoGroundTruth(generatedEnums);
/** @type {string[]} */
const mismatches = [];
for (const { def, members, code } of generatedEnums) {
if (def.stringEnum) continue;
const goValues = goValuesByEnum[def.name];
assert(goValues, `Enum ${def.name} was not outputted from GO`);
const tsValues = await evaluateEnumMembers(code, def.name);
for (const m of members) {
const goValue = goValues[m.name];
const tsValue = tsValues[m.name];
if (tsValue !== goValue) {
mismatches.push(
`${def.name}.${m.name}: Go says ${goValue}, but generated TS (\`${m.value}\`) evaluates to ${tsValue}`,
);
}
}
}

if (mismatches.length > 0) {
throw new Error(
`Generated enum values disagree with Go (likely an operator precedence or transcription bug in generate:enums):\n` +
mismatches.map(m => ` - ${m}`).join("\n"),
);
}
console.log("All generated values match Go.");

await $`dprint fmt ${generatedEnums.flatMap(e => e.fileNames)}`;
console.log("Done.");
}

Expand Down
2 changes: 1 addition & 1 deletion packages/typescript/src/enums/symbolFlags.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export enum SymbolFlags {
ConstEnumOnlyModule = 1 << 28,
ReplaceableByMethod = 1 << 29,
GlobalLookup = 1 << 30,
All = 1 << 30 - 1,
All = (1 << 30) - 1,
Enum = RegularEnum | ConstEnum,
Variable = FunctionScopedVariable | BlockScopedVariable,
Value = Variable | Property | EnumMember | ObjectLiteral | Function | Class | Enum | ValueModule | Method | GetAccessor | SetAccessor,
Expand Down
2 changes: 1 addition & 1 deletion packages/typescript/src/enums/symbolFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export var SymbolFlags: any;
SymbolFlags[SymbolFlags["ConstEnumOnlyModule"] = 268435456] = "ConstEnumOnlyModule";
SymbolFlags[SymbolFlags["ReplaceableByMethod"] = 536870912] = "ReplaceableByMethod";
SymbolFlags[SymbolFlags["GlobalLookup"] = 1073741824] = "GlobalLookup";
SymbolFlags[SymbolFlags["All"] = 536870912] = "All";
SymbolFlags[SymbolFlags["All"] = 1073741823] = "All";
SymbolFlags[SymbolFlags["Enum"] = 384] = "Enum";
SymbolFlags[SymbolFlags["Variable"] = 3] = "Variable";
SymbolFlags[SymbolFlags["Value"] = 111551] = "Value";
Expand Down
21 changes: 21 additions & 0 deletions packages/typescript/test/async/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4353,6 +4353,27 @@ function f() {
await api.close();
}
});

// Regression test: `<<` binds tighter than `-` in Go but looser in JS, so SymbolFlagsAll's
// `(1<<30) - 1` needs those parens or the generated enum silently becomes `1 << 29`.
test("SymbolFlags.All includes both value and type meanings", async () => {
const api = spawnAPI(scopeFiles);
try {
const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" });
const project = snapshot.getProject("/tsconfig.json")!;
const pos = scopeFiles["/src/main.ts"].indexOf("return innerValue");
const symbols = await project.checker.getSymbolsInScope(
{ document: "/src/main.ts", position: pos },
SymbolFlags.All,
);
const names = symbols.map(s => s.name);
assert.ok(names.includes("innerValue"), "should include local value symbol");
assert.ok(names.includes("OuterType"), "should include type symbol declared in file");
}
finally {
await api.close();
}
});
});

describe("Symbol - getDocumentationComment and getJsDocTags", () => {
Expand Down
21 changes: 21 additions & 0 deletions packages/typescript/test/sync/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4361,6 +4361,27 @@ function f() {
api.close();
}
});

// Regression test: `<<` binds tighter than `-` in Go but looser in JS, so SymbolFlagsAll's
// `(1<<30) - 1` needs those parens or the generated enum silently becomes `1 << 29`.
test("SymbolFlags.All includes both value and type meanings", () => {
const api = spawnAPI(scopeFiles);
try {
const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" });
const project = snapshot.getProject("/tsconfig.json")!;
const pos = scopeFiles["/src/main.ts"].indexOf("return innerValue");
const symbols = project.checker.getSymbolsInScope(
{ document: "/src/main.ts", position: pos },
SymbolFlags.All,
);
const names = symbols.map(s => s.name);
assert.ok(names.includes("innerValue"), "should include local value symbol");
assert.ok(names.includes("OuterType"), "should include type symbol declared in file");
}
finally {
api.close();
}
});
});

describe("Symbol - getDocumentationComment and getJsDocTags", () => {
Expand Down
Loading