pruneUnusedImports

tcmts.cli.main.pruneUnusedImports · #im7ez5at · term · runs on the server

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 };
}

uses

used by