subscript
Version:
Modular expression parser & evaluator
521 lines (456 loc) • 16.7 kB
JavaScript
/**
* ESM Bundler using subscript's own parser (dogfooding)
*
* Thin layer: scope analysis + tree transform
* Parser comes from the dialect (jessie by default)
*/
import { parse } from '../jessie.js';
import { codegen } from './stringify.js';
// === AST Utilities ===
/** Walk AST, call fn(node, parent, key) for each node */
const walk = (node, fn, parent = null, key = null) => {
if (!node || typeof node !== 'object') return;
fn(node, parent, key);
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++) walk(node[i], fn, node, i);
}
};
/** Deep clone AST */
const clone = node =>
!node ? node :
Array.isArray(node) ? node.map(clone) :
node instanceof RegExp ? new RegExp(node.source, node.flags) :
typeof node === 'object' ? Object.fromEntries(Object.entries(node).map(([k,v]) => [k, clone(v)])) :
node;
/** Rename identifier in AST - skip property access positions */
const renameId = (ast, old, neu) => {
walk(ast, (node, parent, key) => {
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++) {
if (node[i] === old) {
// Don't rename if this is a property name in a '.' or '?.' access
if ((node[0] === '.' || node[0] === '?.') && i === 2) continue;
// Don't rename if this is a property name in object literal {a: b} or shorthand {a}
if (node[0] === ':' && i === 1 && typeof node[1] === 'string') continue;
node[i] = neu;
}
}
}
});
return ast;
};
/** Flatten comma nodes into array: [',', 'a', 'b'] → ['a', 'b'], 'x' → ['x'] */
const flattenComma = node =>
Array.isArray(node) && node[0] === ',' ? node.slice(1) :
node ? [node] : [];
// === Module Analysis ===
/** Extract string from path node [null, 'path'] (string literal) */
const getPath = node => Array.isArray(node) && (node[0] === undefined || node[0] === null) ? node[1] : node;
/** Extract imports from AST
* New AST shapes:
* import './x.js' → ['import', [null, path]]
* import X from './x.js' → ['import', ['from', 'X', [null, path]]]
* import {a,b} from './x' → ['import', ['from', ['{}', ...], [null, path]]]
* import * as X from './x' → ['import', ['from', ['as', '*', 'X'], [null, path]]]
*/
const getImports = ast => {
const imports = [];
walk(ast, node => {
if (!Array.isArray(node) || node[0] !== 'import') return;
const body = node[1];
const imp = { node };
// import './x.js' - bare import: [, 'path'] sparse array with undefined at index 0
if (Array.isArray(body) && (body[0] === undefined || body[0] === null)) {
imp.path = body[1];
imports.push(imp);
return;
}
// import X from './x.js' or import {...} from './x.js'
if (Array.isArray(body) && body[0] === 'from') {
const spec = body[1];
const pathNode = body[2];
imp.path = getPath(pathNode);
if (typeof spec === 'string') {
// import X from - default import
imp.default_ = spec;
} else if (Array.isArray(spec)) {
if (spec[0] === '{}') {
// import { a, b, c as d }
const items = spec.slice(1).flatMap(flattenComma);
imp.named = items.map(s =>
Array.isArray(s) && s[0] === 'as' ? { name: s[1], alias: s[2] } : { name: s, alias: s }
);
} else if (spec[0] === 'as' && spec[1] === '*') {
// import * as X
imp.namespace = spec[2];
} else if (spec[0] === '*') {
// import * as X (alternate shape)
imp.namespace = spec[1];
}
}
imports.push(imp);
}
});
return imports;
};
/** Extract exports from AST
* New AST shapes:
* export const x = 1 → ['export', ['const', ['=', 'x', val]]]
* export default x → ['export', ['default', 'x']]
* export { a } → ['export', ['{}', 'a']]
* export { a } from './x' → ['export', ['from', ['{}', 'a'], [null, path]]]
* export * from './x' → ['export', ['from', '*', [null, path]]]
*/
const getExports = ast => {
const exports = { named: {}, reexports: [], default_: null };
walk(ast, node => {
if (!Array.isArray(node) || node[0] !== 'export') return;
const spec = node[1];
// export { a } from './x' or export * from './x'
if (Array.isArray(spec) && spec[0] === 'from') {
const what = spec[1];
const pathNode = spec[2];
const path = getPath(pathNode);
if (what === '*') {
exports.reexports.push({ star: true, path });
} else if (Array.isArray(what) && what[0] === '{}') {
const items = what.slice(1).flatMap(flattenComma);
const names = items.map(s =>
Array.isArray(s) && s[0] === 'as' ? { name: s[1], alias: s[2] } : { name: s, alias: s }
);
exports.reexports.push({ names, path });
}
return;
}
// export { a, b }
if (Array.isArray(spec) && spec[0] === '{}') {
const items = spec.slice(1).flatMap(flattenComma);
const names = items.map(s =>
Array.isArray(s) && s[0] === 'as' ? { name: s[1], alias: s[2] } : { name: s, alias: s }
);
for (const { name, alias } of names) exports.named[alias] = name;
return;
}
// export default x
if (Array.isArray(spec) && spec[0] === 'default') {
exports.default_ = spec[1];
return;
}
// export const/let/var x = ... - varargs: ['let', decl1, decl2, ...]
if (Array.isArray(spec) && (spec[0] === 'const' || spec[0] === 'let' || spec[0] === 'var')) {
for (let i = 1; i < spec.length; i++) {
const decl = spec[i];
if (typeof decl === 'string') {
exports.named[decl] = decl;
} else if (Array.isArray(decl) && decl[0] === '=') {
const name = decl[1];
if (typeof name === 'string') exports.named[name] = name;
}
}
return;
}
// export function x() {} or export class x {}
if (Array.isArray(spec) && (spec[0] === 'function' || spec[0] === 'class')) {
if (typeof spec[1] === 'string') exports.named[spec[1]] = spec[1];
}
});
return exports;
};
/** Get all declared names in AST
* New AST shapes:
* const x = 1 → ['const', ['=', 'x', val]]
* let x → ['let', 'x']
* function f() → ['function', 'f', ...]
* const a = 1, b = 2 → ['const', ['=', 'a', ...], ['=', 'b', ...]] (varargs)
*/
const getDecls = ast => {
const decls = new Set();
const addDecl = node => {
if (typeof node === 'string') decls.add(node);
else if (Array.isArray(node)) {
if (node[0] === '=') {
if (typeof node[1] === 'string') decls.add(node[1]);
} else if (node[0] === ',') {
// Multiple declarations: const a = 1, b = 2 (older AST shape)
for (let i = 1; i < node.length; i++) addDecl(node[i]);
}
}
};
walk(ast, node => {
if (!Array.isArray(node)) return;
const op = node[0];
if (op === 'const' || op === 'let' || op === 'var') {
// Handle varargs: ['const', decl1, decl2, ...] for multiple declarations
for (let i = 1; i < node.length; i++) addDecl(node[i]);
}
if (op === 'function' || op === 'class') {
if (typeof node[1] === 'string') decls.add(node[1]);
}
if (op === 'export') {
const spec = node[1];
if (Array.isArray(spec) && (spec[0] === 'const' || spec[0] === 'let' || spec[0] === 'var')) {
addDecl(spec[1]);
}
if (Array.isArray(spec) && (spec[0] === 'function' || spec[0] === 'class') && typeof spec[1] === 'string') {
decls.add(spec[1]);
}
}
});
return decls;
};
// === AST Transforms ===
/** Remove import/export nodes, extract declarations */
/** Remove import/export nodes, extract declarations
* New AST shapes for export:
* export const x = 1 → ['export', ['const', ...]] → keep ['const', ...]
* export default x → ['export', ['default', x]] → keep, or convert to __default
* export { a } → ['export', ['{}', ...]] → remove
* export { a } from './x' → ['export', ['from', ['{}', ...], path]] → remove
* export * from './x' → ['export', ['from', '*', path]] → remove
*/
const stripModuleSyntax = ast => {
const defaultExpr = { value: null };
const process = node => {
if (!Array.isArray(node)) return node;
const op = node[0];
if (op === 'import') return null;
if (op === 'export') {
const spec = node[1];
// Re-exports: export { a } from './x' or export * from './x'
if (Array.isArray(spec) && spec[0] === 'from') return null;
// Named exports: export { a, b }
if (Array.isArray(spec) && spec[0] === '{}') return null;
// Default export
if (Array.isArray(spec) && spec[0] === 'default') {
defaultExpr.value = spec[1];
if (typeof spec[1] === 'string') return null;
return ['const', ['=', '__default', spec[1]]];
}
// Declaration export: export const x = 1
return spec;
}
if (op === ';') {
const parts = node.slice(1).map(process).filter(Boolean);
if (parts.length === 0) return null;
if (parts.length === 1) return parts[0];
return [';', ...parts];
}
return node;
};
return { ast: process(ast), defaultExpr: defaultExpr.value };
};
// === Path Resolution ===
const resolvePath = (from, to) => {
if (!to.startsWith('.')) return to;
const base = from.split('/').slice(0, -1);
for (const part of to.split('/')) {
if (part === '..') base.pop();
else if (part !== '.') base.push(part);
}
let path = base.join('/');
if (!path.endsWith('.js')) path += '.js';
return path;
};
// === Bundler ===
/**
* Bundle ES modules into single file
* @param {string} entry - Entry file path
* @param {Object|Function} read - Source map {path: code} or reader function
*/
export async function bundle(entry, read) {
// Accept object as source map
if (typeof read === 'object') {
const sources = read;
read = path => {
if (!(path in sources)) throw Error(`Module not found: ${path}`);
return sources[path];
};
}
const modules = new Map();
const order = [];
async function load(path) {
if (modules.has(path)) return;
modules.set(path, null);
const code = await read(path);
const ast = parse(code);
const imports = getImports(ast);
const exports = getExports(ast);
const decls = getDecls(ast);
for (const imp of imports) {
imp.resolved = resolvePath(path, imp.path);
await load(imp.resolved);
}
for (const re of exports.reexports) {
re.resolved = resolvePath(path, re.path);
await load(re.resolved);
}
modules.set(path, { ast: clone(ast), imports, exports, decls });
order.push(path);
}
await load(entry);
// Detect conflicts
const allDecls = new Map();
for (const [path, mod] of modules) {
const importAliases = new Set();
for (const imp of mod.imports) {
if (imp.default_) importAliases.add(imp.default_);
if (imp.namespace) importAliases.add(imp.namespace);
if (imp.named) for (const { alias } of imp.named) importAliases.add(alias);
}
for (const name of mod.decls) {
if (importAliases.has(name)) continue;
if (!allDecls.has(name)) allDecls.set(name, []);
allDecls.get(name).push(path);
}
}
// Build rename maps
const renames = new Map();
for (const [name, paths] of allDecls) {
if (paths.length > 1) {
for (const path of paths) {
if (!renames.has(path)) renames.set(path, {});
// Make valid JS identifier: replace non-alphanumeric with underscore
const prefix = path.split('/').pop().replace('.js', '').replace(/[^a-zA-Z0-9]/g, '_') + '_';
renames.get(path)[name] = prefix + name;
}
}
}
const traceDefault = path => {
const mod = modules.get(path);
if (!mod) return null;
const def = mod.exports.default_;
if (!def) return null;
if (typeof def === 'string') {
const pathRenames = renames.get(path) || {};
if (pathRenames[def]) return pathRenames[def];
const defImp = mod.imports.find(i => i.default_ === def);
if (defImp) return traceDefault(defImp.resolved);
return def;
}
return '__default';
};
// Transform each module
const chunks = [];
for (const path of order) {
const mod = modules.get(path);
const pathRenames = renames.get(path) || {};
let ast = clone(mod.ast);
for (const [old, neu] of Object.entries(pathRenames)) {
renameId(ast, old, neu);
}
for (const imp of mod.imports) {
const dep = modules.get(imp.resolved);
if (!dep) continue;
const depRenames = renames.get(imp.resolved) || {};
if (imp.named) {
for (const { name, alias } of imp.named) {
// `name` is the exported name, look up what local name it maps to in the dep
const localName = dep.exports.named[name] || name;
// Check if that local name was renamed in the dep
const resolved = depRenames[localName] || localName;
if (alias !== resolved) renameId(ast, alias, resolved);
}
}
if (imp.default_) {
const resolved = traceDefault(imp.resolved);
if (resolved && imp.default_ !== resolved) {
renameId(ast, imp.default_, resolved);
}
}
if (imp.namespace) {
walk(ast, node => {
if (Array.isArray(node) && node[0] === '.' && node[1] === imp.namespace) {
const prop = node[2];
if (typeof prop === 'string' && dep.exports.named[prop]) {
const resolved = depRenames[dep.exports.named[prop]] || dep.exports.named[prop];
node.length = 0;
node.push(resolved);
}
}
});
}
}
const { ast: stripped } = stripModuleSyntax(ast);
if (stripped) {
const code = codegen(stripped);
if (code.trim()) {
chunks.push(`// === ${path} ===\n${code}`);
}
}
}
// Generate exports
const entryMod = modules.get(entry);
const entryRenames = renames.get(entry) || {};
const exportLines = [];
// Resolve all named exports including from re-exports
const resolveExports = (path, seen = new Set()) => {
if (seen.has(path)) return {};
seen.add(path);
const mod = modules.get(path);
if (!mod) return {};
const pathRenames = renames.get(path) || {};
const result = {};
// Direct named exports
for (const [exp, local] of Object.entries(mod.exports.named)) {
result[exp] = pathRenames[local] || local;
}
// Re-exports
for (const re of mod.exports.reexports) {
const depRenames = renames.get(re.resolved) || {};
if (re.star) {
// export * from './x' - get all exports from that module
const depExports = resolveExports(re.resolved, seen);
for (const [exp, resolved] of Object.entries(depExports)) {
if (!(exp in result)) result[exp] = resolved; // don't override local exports
}
} else if (re.names) {
// export { a, b } from './x'
const depMod = modules.get(re.resolved);
if (depMod) {
for (const { name, alias } of re.names) {
const local = depMod.exports.named[name] || name;
result[alias] = depRenames[local] || local;
}
}
}
}
return result;
};
const allExports = resolveExports(entry);
for (const [exp, resolved] of Object.entries(allExports)) {
exportLines.push(exp === resolved ? exp : `${resolved} as ${exp}`);
}
if (entryMod.exports.default_) {
const resolved = traceDefault(entry) || '__default';
exportLines.push(`${resolved} as default`);
}
let result = chunks.join('\n\n');
if (exportLines.length) {
result += `\n\nexport { ${exportLines.join(', ')} }`;
}
return result;
}
/**
* Bundle with Node.js fs (only available in Node.js environment)
* Dynamically imports fs/path to avoid browser errors
*/
export async function bundleFile(entry) {
const { readFile } = await import('fs/promises');
const { resolve } = await import('path');
return bundle(resolve(entry), path => readFile(path, 'utf-8'));
}
// CLI (Node.js only)
if (typeof process !== 'undefined' && process.argv[1]?.includes('bundle')) {
const entry = process.argv[2];
if (!entry) {
console.error('Usage: node bundle.js <entry>');
process.exit(1);
}
bundleFile(entry)
.then(result => console.log(result))
.catch(e => {
console.error('Error:', e.message);
console.error(e.stack);
process.exit(1);
});
}