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
1 change: 1 addition & 0 deletions apps/oxlint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@arethetypeswrong/core": "catalog:",
"@babel/code-frame": "^7.28.6",
"@napi-rs/cli": "catalog:",
"@oxc-project/types": "^0.118.0",
"@type-challenges/utils": "^0.1.1",
"@types/babel__code-frame": "^7.27.0",
"@types/esquery": "^1.5.4",
Expand Down
325 changes: 4 additions & 321 deletions apps/oxlint/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import fs from "node:fs";
import { join as pathJoin, relative as pathRelative, dirname } from "node:path";
import { defineConfig } from "tsdown";
import { parseSync, Visitor } from "oxc-parser";
// oxlint-disable-next-line typescript/ban-ts-comment
// @ts-ignore - file is generated and not checked in to git
import ruleNames from "./src-js/generated/plugin-eslint/rule_names.ts";

import type { Plugin } from "rolldown";
import replaceGlobalsPlugin from "./tsdown_plugins/replace_globals.ts";
import replaceAssertsPlugin from "./tsdown_plugins/replace_asserts.ts";

const { env } = process;
const isEnabled = (env: string | undefined) => env === "true" || env === "1";
Expand Down Expand Up @@ -89,8 +86,8 @@ for (const ruleName of ruleNames) {

// Plugins.
// Only remove debug assertions in release build.
const plugins = [createReplaceGlobalsPlugin()];
if (!DEBUG) plugins.push(createReplaceAssertsPlugin());
const plugins = [replaceGlobalsPlugin];
if (!DEBUG) plugins.push(replaceAssertsPlugin);

// All build configs
export default defineConfig([
Expand Down Expand Up @@ -156,317 +153,3 @@ export default defineConfig([
},
},
]);

/**
* Create a plugin to remove imports of `debugAssert*` / `typeAssert*` functions from `src-js/utils/asserts.ts`,
* and all their call sites.
*
* ```ts
* // Original code
* import { debugAssertIsNonNull } from '../utils/asserts.ts';
* const foo = getFoo();
* debugAssertIsNonNull(foo.bar);
*
* // After transform
* const foo = getFoo();
* ```
*
* This solves 2 problems:
*
* # 1. Minifier works chunk-by-chunk
*
* Minifier can already remove all calls to these functions as dead code, but only if the functions are defined
* in the same file as the call sites.
*
* Problem is that `asserts.ts` is imported by files which end up in all output chunks.
* So without this transform, TSDown creates a shared chunk for `asserts.ts`. Minifier works chunk-by-chunk,
* so can't see that these functions are no-ops, and doesn't remove the function calls.
*
* # 2. Not entirely removed
*
* Even if minifier does remove all calls to these functions, it can't prove that expressions *inside* the calls
* don't have side effects.
*
* In example above, it can't know if `foo` has a getter for `bar` property.
* So it removes the call to `debugAssertIsNonNull`, but leaves behind the `foo.bar` expression.
*
* ```ts
* const foo = getFoo();
* foo.bar;
* ```
*
* This plugin visits AST and removes all calls to `debugAssert*` / `typeAssert*` functions entirely,
* *including* the expressions inside the calls.
*
* This makes these debug assertion functions act like `debug_assert!` in Rust.
*
* @returns Plugin
*/
function createReplaceAssertsPlugin(): Plugin {
const ASSERTS_PATH = pathJoin(import.meta.dirname, "src-js/utils/asserts.ts");

return {
name: "replace-asserts",
transform: {
// Only process TS files in `src-js` directory
filter: { id: /\/src-js\/.+(?<!\.d)\.ts$/ },

async handler(code, path, meta) {
const magicString = meta.magicString!;
const { program, errors } = parseSync(path, code);
if (errors.length !== 0) throw new Error(`Failed to parse ${path}: ${errors[0].message}`);

// Gather names of assertion functions imported from `asserts.ts`.
// Also gather all identifiers used in the `import` statements, so can avoid erroring on them in visitor below.
const assertFnNames: Set<string> = new Set(),
idents = new Set();
for (const stmt of program.body) {
if (stmt.type !== "ImportDeclaration") continue;

// Check if import is from `utils/asserts.ts`.
// `endsWith` check is just a shortcut to avoid resolving the specifier to a full path for most imports.
const source = stmt.source.value;
if (!source.endsWith("/asserts.ts") && !source.endsWith("/asserts.js")) continue;
// oxlint-disable-next-line no-await-in-loop
const importedId = await this.resolve(source, path);
if (importedId === null || importedId.id !== ASSERTS_PATH) continue;

// Remove `import` statement
for (const specifier of stmt.specifiers) {
if (specifier.type !== "ImportSpecifier") {
throw new Error(`Only use named imports when importing from \`asserts.ts\`: ${path}`);
}
idents.add(specifier.local);
if (specifier.imported.type === "Identifier") idents.add(specifier.imported);
assertFnNames.add(specifier.local.name);
}
magicString.remove(stmt.start, stmt.end);
}

if (assertFnNames.size === 0) return;

// Visit AST and remove all calls to assertion functions
const visitor = new Visitor({
// Replace `debugAssert(...)` calls with `null`. Minifier will remove the `null`.
CallExpression(node) {
const { callee } = node;
if (callee.type !== "Identifier") return;
if (assertFnNames.has(callee.name)) {
idents.add(callee);
magicString.overwrite(node.start, node.end, "null");
}
},
// Error if assertion functions are used in any other way. We lack logic to deal with that.
Identifier(node) {
const { name } = node;
if (assertFnNames.has(name) && !idents.has(node)) {
throw new Error(
`Do not use \`${name}\` imported from \`asserts.ts\` except in function calls: ${path}`,
);
}
},
});
visitor.visit(program);

return { code: magicString };
},
},
};
}

// prettier-ignore
const GLOBALS = new Set([
"Object", "Array", "Math", "JSON", "Reflect", "Symbol", "Function", "Number", "Boolean", "String", "Date", "Promise",
"RegExp", "BigInt", "Map", "Set", "Error", "AggregateError", "EvalError", "RangeError", "ReferenceError",
"SyntaxError", "TypeError", "URIError", "Buffer", "ArrayBuffer", "SharedArrayBuffer", "Atomics", "Uint8Array",
"Int8Array", "Uint16Array", "Int16Array", "Uint32Array", "Int32Array", "BigUint64Array", "BigInt64Array",
"Uint8ClampedArray", "Float32Array", "Float64Array", "Float16Array", "DataView", "WebAssembly", "Iterator",
"WeakMap", "WeakSet", "Proxy", "FinalizationRegistry", "WeakRef", "URL", "URLSearchParams", "TextEncoder",
"TextDecoder", "BroadcastChannel", "MessageChannel", "MessagePort", "Blob", "File"
]);

// Global properties which cannot be converted to top-level vars, because they're methods which use `this`.
// e.g. `const r = Promise.resolve; r(1);` throws "TypeError: PromiseResolve called on non-object".
const SKIP_GLOBALS = new Set(["Promise.resolve", "Promise.allSettled"]);

/**
* Create a plugin to replace usage of properties of globals with global vars defined in `utils/globals.ts`.
*
* This is more performant, due to reduced property lookups, and minifies better.
*
* ```ts
* // Original code
* const keys = Object.keys(obj);
*
* // After transform
* import { ObjectKeys } from "../utils/globals.ts";
* const keys = ObjectKeys(obj);
* ```
*
* If TSDown produces any errors about missing imports, likely you need to add the missing global(s)
* to `utils/globals.ts`.
*/
function createReplaceGlobalsPlugin(): Plugin {
// Path to file which exports global vars
const GLOBALS_PATH = pathJoin(import.meta.dirname, "src-js/utils/globals.ts");

// Parse the file to get the list of global vars it exports
const availableGlobals = getAvailableGlobals(GLOBALS_PATH);

return {
name: "replace-globals",
transform: {
// Only process TS files in `src-js` directory
filter: { id: /\/src-js\/.+(?<!\.d)\.ts$/ },

async handler(code, path, meta) {
const magicString = meta.magicString!;
const { program, errors } = parseSync(path, code);
if (errors.length !== 0) throw new Error(`Failed to parse ${path}: ${errors[0].message}`);

// Visit AST and replace all references to globals with top-level vars
const varNames = new Set<string>(),
visitedMemberExpressions = new Set(),
missingGlobalVars = new Set<string>();

const visitor = new Visitor({
MemberExpression(node) {
// Skip nested `MemberExpression`s e.g. `Object.prototype` in `Object.prototype.toString`
if (visitedMemberExpressions.has(node)) return;

// Exit if computed (`obj[prop]`) or private property (`obj.#prop`).
let { object, property } = node;
if (node.computed || property.type !== "Identifier") return;

// Gather all properties in reverse order.
// e.g. `Object.prototype.toString` -> `propNames = ["toString", "prototype"]`.
const propNames: string[] = [property.name];
while (true) {
// If `object` is an identifier, `node` is a member expression of form `a.b`, `a.b.c`, etc.
if (object.type === "Identifier") break;

// If `object` is not a member expression, exit e.g. `foo().x`
if (object.type !== "MemberExpression") return;

// We can't handle deep nesting yet
// oxlint-disable-next-line no-constant-condition
if (1) return;

// Avoid processing the nested member expression again when it's visited later
visitedMemberExpressions.add(object);

// Exit if computed (`obj[prop]`) or private property (`obj.#prop`).
property = object.property;
if (object.computed || property.type !== "Identifier") return;

// `node` of form `<SOMETHING>.a.b` or `<SOMETHING>.a.b.c`.
// Loop round to process the `<SOMETHING>` part.
propNames.push(property.name);

object = object.object;
}

// Found a member expression of form `obj.a`, or `obj.a.b`, `obj.a.b.c`, etc.
// Exit if `obj` is not a global.
const globalName = object.name;
if (!GLOBALS.has(globalName)) return;

const propName = propNames.reverse().join(".");

const fullName = `${object.name}.${propName}`;
if (SKIP_GLOBALS.has(fullName)) return;

const mapping = availableGlobals.get(globalName);
if (!mapping) {
missingGlobalVars.add(`\`${fullName}\``);
return;
}

const varName = mapping.get(propName);
if (!varName) {
missingGlobalVars.add(`\`${fullName}\``);
return;
}

// Add var name (e.g. `ObjectHasOwn`) to set of vars to import
varNames.add(varName);

// Replace `Object.hasOwn` with `ObjectHasOwn`
magicString.overwrite(node.start, node.end, varName);
},
});
visitor.visit(program);

// Log any globals that were not converted because `utils/globals.ts` has no export for them
if (missingGlobalVars.size > 0) {
// oxlint-disable-next-line no-console
console.error(
"--------------------------------------------------------------------------------\n" +
`WARNING: Unable to convert ${[...missingGlobalVars].join(" or ")} to global vars.\n` +
`Add exports to \`utils/globals.ts\` for them.\n` +
"--------------------------------------------------------------------------------",
);
}

if (varNames.size === 0) return;

// Some globals were found. Import them from `utils/globals.ts`.
let relativePath = pathRelative(dirname(path), GLOBALS_PATH);
relativePath = relativePath.replace(/\\/g, "/");
relativePath = relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
const importStmt = `import { ${[...varNames].join(", ")} } from ${JSON.stringify(relativePath)};\n`;

magicString.prepend(importStmt);

return { code: magicString };
},
},
};
}

/**
* Parse `utils/globals.ts` and return a list of globals and global vars it exports.
* @param path - Path to `utils/globals.ts`
* @returns Mapping from global name (e.g. `Object`) to mapping of properties of that global to var names
* (e.g. `hasOwn` -> `ObjectHasOwn`).
*/
function getAvailableGlobals(path: string): Map<string, Map<string, string>> {
const code = fs.readFileSync(path, "utf8");
const { program, errors } = parseSync(path, code);
if (errors.length !== 0) throw new Error(`Failed to parse ${path}: ${errors[0].message}`);

const globals = new Map<string, Map<string, string>>();

const visitor = new Visitor({
ExportNamedDeclaration(node) {
const { declaration } = node;
if (declaration == null || declaration.type !== "VariableDeclaration") return;
const declarator = declaration.declarations[0];
if (!declarator) return;
const { init } = declarator;
if (!init || init.type !== "Identifier") return;

const obj = declarator.id;
if (obj.type !== "ObjectPattern") return;

const globalName = init.name;
let mapping = globals.get(globalName);
if (!mapping) {
mapping = new Map();
globals.set(globalName, mapping);
}

for (const prop of obj.properties) {
if (prop.type !== "Property" || prop.method || prop.computed) continue;

const { key, value } = prop;
if (key.type !== "Identifier" || value.type !== "Identifier") continue;

mapping.set(key.name, value.name);
}
},
});
visitor.visit(program);

return globals;
}
Loading
Loading