Three modules
This example takes a small program of three files and answers three questions about it:
- Which names does each file import from the other two?
- Where is every export used, across the whole program?
- Which functions change the score?
The parser reads one file at a time and knows nothing about the other files. Each parse lists what the file declares and what it uses. Your code connects the files through their import and export statements.
Everything here builds on Scopes, which explains bindings and references.
The program is a text adventure: the map, the player, the game. Click a module to read it.
1. Parse each file
import { readFileSync } from 'node:fs';
import { Source, referenceOf, parentOf, scopeOf } from '@teasel/parser';
const modules = new Map();
for (const name of ['world.js', 'player.js', 'game.js']) {
const text = readFileSync(`adventure/${name}`, 'utf8');
const source = new Source(text, { sourceType: 'module', scopes: true });
modules.set(name, { name, text, ...source.parse() });
}
const lineOf = (module, node) =>
module.text.slice(0, node.start).split('\n').length;One Source holds one text, so three files are three Sources.
2. What each file exports
export const x and export function f are statements in the tree, of type ExportNamedDeclaration. The declaration inside has the identifier that names the export.
function exportsOf(module) {
const exports = new Map();
for (const statement of module.node.body) {
const { declaration } = statement;
if (statement.type !== 'ExportNamedDeclaration' || !declaration) continue;
const ids = declaration.type === 'VariableDeclaration'
? declaration.declarations.map((declarator) => declarator.id)
: [declaration.id];
for (const id of ids) exports.set(id.name, referenceOf(id));
}
return exports;
}
for (const module of modules.values()) module.exports = exportsOf(module);Each file now has exports, a map from an exported name to its binding. This reads export const, export function and export class. A program that also uses export { a, b } or export default needs those two statement types handled the same way.
3. Match each import to the export it names
An import statement names a file and some names. When the file is one of the three, each imported name is looked up in that file's exports.
const importedFrom = new Map();
for (const module of modules.values()) {
for (const statement of module.node.body) {
if (statement.type !== 'ImportDeclaration') continue;
const from = modules.get(statement.source.value.replace('./', ''));
if (!from) continue;
for (const specifier of statement.specifiers) {
const local = referenceOf(specifier.local);
const binding = from.exports.get(specifier.imported.name);
importedFrom.set(local, { importer: module, module: from, binding });
}
}
}With the map in place, the first question has an answer. usesOf counts the references in a file that point at a binding.
const usesOf = (module, binding) =>
module.references.filter((reference) => reference.binding === binding).length;
for (const module of modules.values()) {
const imports = module.bindings.filter((binding) => binding.kind === 'import');
for (const local of imports) {
const target = importedFrom.get(local);
let where = 'from outside the program';
if (target) {
const { kind, node } = target.binding;
const line = lineOf(target.module, node);
const uses = usesOf(module, local);
where = `${kind} in ${target.module.name} line ${line}, used ${uses}x here`;
}
console.log(`${module.name.padEnd(10)} ${local.name.padEnd(16)} ${where}`);
}
}world.js describe from outside the program
world.js say from outside the program
player.js rooms const in world.js line 6, used 1x here
game.js roll from outside the program
game.js say from outside the program
game.js createInterface from outside the program
game.js DIRECTIONS const in world.js line 3, used 1x here
game.js LAMP_LIFE const in world.js line 4, used 2x here
game.js look function in world.js line 31, used 3x here
game.js restock function in world.js line 40, used 1x here
game.js Player class in player.js line 5, used 1x here4. Where every export is used
An export is used in two places: in its own file, and in every file that imports it. The second kind is found by going through importedFrom for the imports that point at it.
for (const module of modules.values()) {
for (const [name, binding] of module.exports) {
const elsewhere = [];
for (const [local, target] of importedFrom) {
if (target.binding !== binding) continue;
elsewhere.push(`${target.importer.name} ${usesOf(target.importer, local)}x`);
}
const here = usesOf(module, binding);
const others = elsewhere.join(', ') || 'imported nowhere';
const row = [module.name.padEnd(10), name.padEnd(11), `used here ${here}x;`, others];
console.log(row.join(' '));
}
}world.js DIRECTIONS used here 0x; game.js 1x
world.js LAMP_LIFE used here 0x; game.js 2x
world.js rooms used here 5x; player.js 1x
world.js look used here 0x; game.js 3x
world.js restock used here 0x; game.js 1x
player.js Player used here 0x; game.js 1x
game.js parse used here 1x; imported nowhere
game.js step used here 1x; imported nowhere
game.js won used here 2x; imported nowhere
game.js play used here 0x; imported nowhere
game.js reset used here 0x; imported nowheregame.js is where the program starts, so no other file imports its exports. An export in any other file with no uses here and no importer would be dead code.
5. Which functions change the score
game.js keeps the score, the turn count and the lamp in three top-level lets. A reference has write set when it assigns to its binding, and scope, the scope it sits in. Walking scope.parent upward reaches the function the assignment is in.
const game = modules.get('game.js');
function functionAround(reference) {
for (let scope = reference.scope; scope; scope = scope.parent) {
if (scope.kind === 'function') return scope.node;
}
return null;
}
function nameOf(fn) {
if (fn.id) return fn.id.name;
const parent = parentOf(fn);
const keyed = parent.type === 'Property' || parent.type === 'MethodDefinition';
if (keyed) return parent.key.name;
if (parent.type === 'VariableDeclarator') return parent.id.name;
return '(anonymous)';
}
const state = game.bindings.filter(
(binding) => binding.kind === 'let' && binding.scope === scopeOf(game.node),
);
for (const binding of state) {
const writes = game.references.filter(
(reference) => reference.binding === binding && reference.write,
);
const where = writes.map((reference) => {
const fn = nameOf(functionAround(reference));
return `${fn}:${lineOf(game, reference.node)}`;
});
console.log(`${binding.name.padEnd(10)} ${where.join(', ')}`);
}turns step:50, reset:77
score eat:21, act:28, reset:78
lampTurns step:52, reset:79The write at line 21 is score += roll(6), inside an arrow function stored as a property: eat: (player, item) => { … }. An arrow function has no id, so nameOf reads the name from the property that holds it.
Related
- Scopes has everything a binding, a reference and a scope carry.
- Svelte component connects names across parses of one file instead of across files.