teasel
Search
Search the documentation

Three modules

This example takes a small program of three files and answers three questions about it:

  1. Which names does each file import from the other two?
  2. Where is every export used, across the whole program?
  3. 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.

adventure/world.jsplayer.jsgame.js
the file, highlighted

1. Parse each file

link-modules.js
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.

link-modules.js
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.

link-modules.js
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.

link-modules.js
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 here

4. 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.

link-modules.js
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 nowhere

game.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.

link-modules.js
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:79

The 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.

  • Scopes has everything a binding, a reference and a scope carry.
  • Svelte component connects names across parses of one file instead of across files.