pruneUnusedImports
uses tcmts.core.ts.parse, which uses tcmts.core.ts.compiler, which uses process
A scratch file's text without the imports nothing in it uses, as saving it takes them out: a name an import binds
that the rest of the file never mentions. Mentioned is any word outside the imports, in code or not: a comment's
{@link}, a string, either side of a conflict's markers (an import kept that isn't needed is harmless; one taken
out that is isn't). An import with nothing left goes, its line with it; one binding nothing (import "./setup",
for what running it does) stays, and so does React in a .tsx file (JSX may use it without naming it). The text,
and the names taken out.
import { applyEdits } from "#codebase/tcmts/cli/mirror"; import type { Edit } from "#codebase/tcmts/cli/mirror"; import { parse } from "#codebase/tcmts/core/ts"; import { isImportDeclaration as ts_isImportDeclaration, isNamespaceImport as ts_isNamespaceImport, } from "typescript/ast"; export function pruneUnusedImports( file: string, text: string, ): { text: string; dropped: string[] } { const sf = parse(file, text); const imports = sf.statements.filter(ts_isImportDeclaration); if (!imports.length) return { text, dropped: [] }; // every word outside the imports let rest = ""; let from = 0; for (const st of imports) { rest += `${text.slice(from, st.getStart(sf))}\n`; from = st.end; } const mentioned = new Set(`${rest}${text.slice(from)}`.match(/[A-Za-z_$][\w$]*/g) ?? []); if (file.endsWith(".tsx")) mentioned.add("React"); const edits: Edit[] = []; const dropped: string[] = []; for (const st of imports) { const clause = st.importClause; if (!clause) continue; const bindings = clause.namedBindings; const named = bindings && !ts_isNamespaceImport(bindings) ? [...bindings.elements] : []; const names = [ ...(clause.name ? [clause.name.text] : []), ...(bindings && ts_isNamespaceImport(bindings) ? [bindings.name.text] : []), ...named.map((e) => e.name.text), ]; const unused = names.filter((n) => !mentioned.has(n)); if (!unused.length) continue; dropped.push(...unused); if (unused.length === names.length) { // the whole import, and the rest of its line if that's only space const eol = /^[ \t]*\r?\n/.exec(text.slice(st.end)); edits.push({ start: st.getStart(sf), end: st.end + (eol ? eol[0].length : 0), text: "" }); continue; } // some of it stays: the default name (and its comma), or the bindings after it, or some of the named ones if (clause.name && unused.includes(clause.name.text)) edits.push({ start: clause.name.getStart(sf), end: bindings!.getStart(sf), text: "" }); else if ( clause.name && bindings && (ts_isNamespaceImport(bindings) || named.every((e) => unused.includes(e.name.text))) ) edits.push({ start: clause.name.end, end: bindings.end, text: "" }); if (!named.length || named.every((e) => unused.includes(e.name.text))) continue; // (each one taken out with what's between it and the next; a run at the end, with what's before it) const lastKept = named.findLastIndex((e) => !unused.includes(e.name.text)); for (const [i, e] of named.entries()) { if (!unused.includes(e.name.text)) continue; if (i < lastKept) edits.push({ start: e.getStart(sf), end: named[i + 1]!.getStart(sf), text: "" }); else { edits.push({ start: named[lastKept]!.end, end: named.at(-1)!.end, text: "" }); break; } } } return { text: applyEdits(text, edits), dropped }; }