addToScratch

tcmts.cli.main.addToScratch · #2imw4qlw · term · runs on the server

uses tcmts.cli.mirror.declarations, which uses tcmts.core.ts.parse, which uses tcmts.core.ts.compiler, which uses process

A scratch file's text with more definitions in it (text, rendered as tcm edit writes them): their imports after the file's own, without the names it imports already, and their code before the first of the file's that uses them (a constant's value, say: declared before it, as TypeScript wants), unless they use what's declared after that, else at the end. A name from a module the file imports from already joins that import, if it's the same kind (values, or types: import type stays apart); the file's imports from the codebase of what they declare go (it has them now: the names mean the same, as tcm edit writes them).

import { importedLocals, importLines } from "#codebase/tcmts/cli/main";
import { declarations } from "#codebase/tcmts/cli/mirror";
import { isTypeOnlyImport, parse } from "#codebase/tcmts/core/ts";
import { groupImports } from "#codebase/tcmts/loader";
import { isImportDeclaration as ts_isImportDeclaration, isNamedImports as ts_isNamedImports } from "typescript/ast";

export function addToScratch(file: string, existing: string, text: string): string {
  const declaredNow = new Set(declarations(file, text).keys());
  for (const st of parse(file, existing).statements.filter(ts_isImportDeclaration).reverse()) {
    const spec = (st.moduleSpecifier as { text?: string }).text ?? "";
    if (!/^#codebase(\/|$)/.test(spec)) continue;
    const ls = importLines(st, existing);
    if (!ls.some((l) => declaredNow.has(l.local))) continue;
    const keep = ls.filter((l) => !declaredNow.has(l.local)).map((l) => l.line);
    const to = st.end + (!keep.length && existing[st.end] === "\n" ? 1 : 0);
    existing = `${existing.slice(0, st.getStart())}${keep.length ? groupImports(keep).join("\n") : ""}${existing.slice(to)}`;
  }
  const sf = parse(file, existing);
  const had = sf.statements.filter(ts_isImportDeclaration);
  // (names it imports, or declares itself)
  const bound = new Set([
    ...had.flatMap((st) => importedLocals(st)),
    ...declarations(file, existing).keys(),
  ]);
  const added = parse(file, text).statements.filter(ts_isImportDeclaration);
  const lines = added.flatMap((st) =>
    importLines(st, text)
      .filter((l) => !bound.has(l.local))
      .map((l) => l.line),
  );
  // named imports from a module it has a named import from: into that one (a type into a value import as `type T`)
  const joins = new Map<number, string[]>();
  const apart: string[] = [];
  for (const line of lines) {
    const m = /^import (type )?\{ (.+) \} from ("[^"]+");$/.exec(line);
    const into = m
      ? had.findIndex((st) => {
          const nb = st.importClause?.namedBindings;
          const typeOnly = !!st.importClause && isTypeOnlyImport(st.importClause);
          return (
            (st.moduleSpecifier as { text?: string }).text === JSON.parse(m[3]!) &&
            !!nb &&
            ts_isNamedImports(nb) &&
            typeOnly === !!m[1]
          );
        })
      : -1;
    if (m && into >= 0) {
      joins.set(into, [...(joins.get(into) ?? []), ...m[2]!.split(", ")]);
    } else apart.push(line);
  }
  for (const [i, names] of [...joins].sort((a, b) => b[0] - a[0])) {
    const nb = had[i]!.importClause!.namedBindings!;
    const all = [...(ts_isNamedImports(nb) ? nb.elements.map((e) => e.getText(sf)) : []), ...names];
    existing = `${existing.slice(0, nb.getStart(sf))}{ ${all.join(", ")} }${existing.slice(nb.end)}`;
  }
  const have = joins.size ? parse(file, existing).statements.filter(ts_isImportDeclaration) : had;
  const imports = apart.length ? groupImports(apart) : [];
  const code = text.slice(added.at(-1)?.end ?? 0).trim();
  const last = have.at(-1);
  // after the last import, or else the namespace line, or else at the top
  const pragma = /^\/\/ tcm: namespace \S+\n/.exec(existing);
  const at = last
    ? last.end + (existing[last.end] === "\n" ? 1 : 0)
    : pragma
      ? pragma[0].length
      : 0;
  const head = existing.slice(0, at) + (imports.length ? imports.join("\n") + "\n" : "");
  let rest = existing.slice(at);
  // before the first statement using what's added, if what's added uses nothing the file declares from there on
  const esc = (n: string) => n.replace(/[$]/g, "\\$&");
  const words = (ns: string[]) =>
    ns.length ? new RegExp(`(?<![\\w$.])(?:${ns.map(esc).join("|")})(?![\\w$])`) : undefined;
  const now = parse(file, existing);
  const statements = now.statements.filter((st) => !ts_isImportDeclaration(st));
  const uses = words([...declaredNow]);
  const first = uses
    ? statements.findIndex((st) => uses.test(existing.slice(st.getStart(now), st.end)))
    : -1;
  const needs = words([...declarations(file, existing).keys()].filter((n) => !declaredNow.has(n)));
  const needed = needs
    ? statements.findLastIndex((st) => {
        const m =
          /(?:const|let|var|function\*?|class|type|interface|enum)\s+([A-Za-z_$][\w$]*)/.exec(
            existing.slice(st.getStart(now), st.end),
          );
        return !!m && new RegExp(`(?<![\\w$.])${esc(m[1]!)}(?![\\w$])`).test(code);
      })
    : -1;
  const user = statements[first];
  let placed = false;
  if (user && needed < first) {
    const trivia = existing.slice(user.pos, user.getStart(now));
    const from = user.pos + trivia.length - trivia.trimStart().length - at;
    if (from >= 0) {
      rest = `${rest.slice(0, from)}${code}\n\n${rest.slice(from)}`;
      placed = true;
    }
  }
  return `${head}${!last && imports.length && rest.trim() ? "\n" : ""}${rest.trimEnd()}${placed ? "" : `\n\n${code}`}\n`.replace(
    /\n{3,}/g,
    "\n\n",
  );
}

uses

used by