patchMany

tcmts.cli.main.patchMany · #x2zvcujs · term · runs on the server

uses node:fs

tcm patch --patches: change several definitions at once, each by find-and-replace on its code as tcm view shows it, and save them together, as one update (a signature and the code calling it, say, or code and the page documenting it): checked together, one history entry, dependents re-hashed once. Each edit applies within its own definition, as tcm view shows it, whatever writing them together takes (patchedTogether); a page, or another file kept as written, is patched as its text, as tcm patch does one. Nothing is saved if an edit doesn't apply, or the code doesn't typecheck (but definitions declared alike, which one file can't hold, are saved one file after another, and a later one is checked once the first is saved). A patch's imports are added to the code's (they go if nothing uses them).

import type { Codebase } from "#codebase/tcmts/cli/codebase";
import { lineDiff } from "#codebase/tcmts/cli/diff";
import { fileName } from "#codebase/tcmts/cli/files";
import {
  apply, applyPatch, edit, entryName, fileRows, importMissing, keptAs, locationRewriter, patchedTogether,
  patchImportLines, replacedUses, rewritingOutput, save,
} from "#codebase/tcmts/cli/main";
import type { PatchEdit, PatchImport, Row } from "#codebase/tcmts/cli/main";
import { dim, green, red, yellow } from "#codebase/tcmts/cli/style";
import { UsageError } from "#codebase/tcmts/cli/ui";
import { materialize } from "#codebase/tcmts/loader";
import fs from "node:fs";
import path from "node:path";

export function patchMany(
  cb: Codebase,
  patches: { name: string; edits: PatchEdit[]; all?: boolean; imports?: PatchImport[] }[],
): boolean {
  if (!patches.length) throw new UsageError("--patches is empty");
  const recsOf = (n: string) =>
    (n.startsWith("#") ? [cb.lookupOne(n)] : cb.lookup(n)).map((h) => cb.store.getDef(h)!);
  const files: {
    name: string;
    edits: PatchEdit[];
    all?: boolean;
    rec: ReturnType<typeof recsOf>[number];
  }[] = [];
  const code: { name: string; edits: PatchEdit[]; all?: boolean; imports?: PatchImport[] }[] = [];
  const named: string[] = [];
  for (const p of patches) {
    const recs = recsOf(p.name);
    if (!recs.length) throw new UsageError(cb.notFound(p.name));
    const kept = recs.find((r) => r.kind === "file");
    if (kept && p.imports?.length)
      throw new UsageError(`${p.name} is a file kept as written: it has no imports to add to`);
    if (kept) files.push({ ...p, rec: kept });
    else code.push(p);
    named.push(p.name.startsWith("#") ? (cb.store.pathsOf(recs[0]!.hash)[0] ?? p.name) : p.name);
  }
  // one history entry, named by what's patched
  const command = entryName("update", named);
  const log = console.log;
  const quietly = <T>(f: () => T): T => {
    console.log = () => {};
    try {
      return f();
    } finally {
      console.log = log;
    }
  };
  const dirs: string[] = [];
  let file: string | undefined;
  try {
    // files kept as written, each written as itself (in a directory of its own) and patched: every edit is applied
    // before anything is saved
    const texts: { name: string; file: string; ns: string; before: string; after: string }[] = [];
    for (const p of files) {
      const name = cb.store.pathsOf(p.rec.hash)[0] ?? p.name;
      const dir = fs.mkdtempSync(path.join(cb.sessionDir ?? cb.lay.root, ".patch-"));
      dirs.push(dir);
      const f = path.join(dir, fileName(name.slice(name.lastIndexOf(".") + 1), p.rec));
      quietly(() => edit(cb, [p.name], f, true));
      const before = fs.readFileSync(f, "utf8");
      const r = applyPatch(before, p.edits, !!p.all);
      if ("problem" in r)
        throw new UsageError(
          `${p.name}: ${r.problem}; nothing was changed (\`tcm view ${p.name}\` shows its text)`,
        );
      texts.push({
        name: p.name,
        file: f,
        ns: name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : "",
        before,
        after: r.text,
      });
    }
    // definitions declared alike (`find` from two namespaces) can't be written in one file, and a function's or class's
    // name is part of its hash, so it can't be given another: a file of its own holds the second, saved after the
    // first in the same history entry (unless they're in one cycle, which is saved whole)
    const parts: { name: string; edits: PatchEdit[]; all?: boolean; imports?: PatchImport[] }[][] =
      [];
    const partNames: Set<string>[] = [];
    const partCycles: Set<string>[] = [];
    for (const p of code) {
      const recs = recsOf(p.name);
      const declared = recs.filter((r) => r.nominal).map((r) => r.nameHint);
      const cycles = recs.map((r) => r.componentHash);
      let i = partCycles.findIndex((c) => cycles.some((h) => c.has(h)));
      if (i < 0) i = partNames.findIndex((n) => !declared.some((d) => n.has(d)));
      if (i < 0) {
        i = parts.length;
        parts.push([]);
        partNames.push(new Set());
        partCycles.push(new Set());
      }
      parts[i]!.push(p);
      for (const d of declared) partNames[i]!.add(d);
      for (const h of cycles) partCycles[i]!.add(h);
    }
    // every edit applies (or nothing is saved), and the change is shown: as each part is, now
    const planned = parts.map((part) => patchedTogether(cb, part));
    const views = [...planned.flatMap((t) => t.views), ...texts].filter(
      (v) => v.after !== v.before,
    );
    if (!views.length) {
      console.log(dim("no change"));
      return true;
    }
    // what they used that's replaced: the current one, as `tcm edit` says, or by its hash (from the cache)
    const used = replacedUses(
      cb,
      code.flatMap((p) => recsOf(p.name)),
    );
    if (used.byHash.length) materialize(used.byHash, cb.source(), cb.lay, { keepExisting: true });
    for (const n of used.notes) console.log(dim(`  ${n}`));
    const asked = [...new Set(code.flatMap((p) => patchImportLines(p.imports ?? [])))];
    if (asked.length) {
      console.log(dim("imports:"));
      for (const line of asked) console.log(green(`  + ${line}`));
    }
    for (const v of views) {
      console.log(dim(`${v.name}:`));
      for (const [op, line] of lineDiff(v.before.split("\n"), v.after.split("\n"))) {
        if (op === "-") console.log(red(`  - ${line}`));
        else if (op === "+") console.log(green(`  + ${line}`));
      }
      for (const n of ("notes" in v && v.notes) || []) console.log(yellow(`note: ${n}`));
    }
    console.log();
    for (const [n, part] of parts.entries()) {
      // (a part after the first, against the codebase as the ones before it left it)
      const together = n ? patchedTogether(cb, part) : planned[0]!;
      if (!together.views.some((v) => v.after !== v.before)) continue;
      file = path.join(
        cb.sessionDir ?? cb.lay.root,
        `patch-${process.pid}${together.jsx ? ".tsx" : ".ts"}`,
      );
      fs.writeFileSync(file, together.text);
      // what the checker says of the file, said of the definitions (the file is gone after)
      const at = locationRewriter(
        file,
        together.places,
        part.map((p) => p.name),
      );
      // names the new code uses that it didn't import (if it has a name the old code didn't: finding out takes a check)
      const words = (s: string) => new Set(s.match(/[A-Za-z_$][\w$]*/g) ?? []);
      if (
        together.views.some((v) => {
          const had = words(v.before);
          return [...words(v.after)].some((w) => !had.has(w));
        })
      )
        for (const line of rewritingOutput(at, () => importMissing(cb, file!, part[0]!.name)))
          console.log(dim(`  ${line}`));
      // (imports it added are lines above the definitions)
      const f = file;
      const by = fs.readFileSync(f, "utf8").split("\n").length - together.text.split("\n").length;
      const shifted = locationRewriter(
        f,
        together.places.map((p) => ({ ...p, line: p.line + by, end: p.end + by })),
        part.map((p) => p.name),
      );
      if (
        !rewritingOutput(shifted, () =>
          save(cb, f, "update", undefined, {
            command,
            mates: together.mates,
            respell: together.respell,
          }),
        )
      )
        return false;
      fs.rmSync(f, { force: true });
    }
    // the files after the code (a page saying what the code now does), in the same history entry
    // (all at once: what uses them is re-hashed once)
    const rows: Row[] = [];
    for (const t of texts) {
      if (t.after === t.before) continue;
      fs.writeFileSync(t.file, t.after);
      const format = keptAs(cb, t.file, t.ns);
      const r = format !== undefined && fileRows(cb, t.file, t.ns, format);
      if (!r) return false;
      rows.push(...r);
    }
    return !rows.length || apply(cb, rows, "update", command, false);
  } finally {
    if (file) fs.rmSync(file, { force: true });
    for (const d of dirs) fs.rmSync(d, { recursive: true, force: true });
  }
}

uses

used by