patch

tcmts.cli.main.patch · #hvnzwzrb · term · runs on the server

uses node:fs

tcm patch <name>: change a definition by find-and-replace on its code as tcm view shows it (current names), then save it as tcm update does: checked, its dependents re-hashed. Nothing is saved if an edit doesn't apply or the result doesn't typecheck. The change is shown as lines removed and added. A file kept as written (a page, a script) is patched as its text, and saved back as the file it is. imports are written above the code, for what importing the names it uses can't find (an alias, say): they go if it doesn't use them. A place in the definition (a problem the checker finds, the line nearest an edit that isn't there) is said by its line as tcm view numbers it, the edits made (but not the imports added).

import type { Codebase } from "#codebase/tcmts/cli/codebase";
import { lineDiff } from "#codebase/tcmts/cli/diff";
import { fileName } from "#codebase/tcmts/cli/files";
import {
  applyPatch, edit, importMissing, patchImportLines, patchLocations, replacedUses, rewritingOutput, save,
} from "#codebase/tcmts/cli/main";
import type { PatchEdit, PatchImport } from "#codebase/tcmts/cli/main";
import { dim, green, red, yellow } from "#codebase/tcmts/cli/style";
import { UsageError } from "#codebase/tcmts/cli/ui";
import fs from "node:fs";
import path from "node:path";

export function patch(
  cb: Codebase,
  name: string,
  edits: PatchEdit[],
  all: boolean,
  imports: PatchImport[] = [],
): boolean {
  const jsx = cb.lookup(name).some((h) => cb.store.getDef(h)?.jsx);
  const kept = cb
    .lookup(name)
    .map((h) => cb.store.getDef(h))
    .find((r) => r?.kind === "file");
  // a scratch file of its own, where the codebase's are (so `#codebase` resolves), gone afterwards; a file, under
  // its own name (in a directory of its own), which is how it's saved back
  const dir = kept ? fs.mkdtempSync(path.join(cb.sessionDir ?? cb.lay.root, ".patch-")) : undefined;
  const named = kept && name.startsWith("#") ? (cb.store.pathsOf(kept.hash)[0] ?? name) : name;
  const ns = named.includes(".") ? named.slice(0, named.lastIndexOf(".")) : "";
  const file = kept
    ? path.join(dir!, fileName(named.slice(named.lastIndexOf(".") + 1), kept))
    : path.join(cb.sessionDir ?? cb.lay.root, `patch-${process.pid}${jsx ? ".tsx" : ".ts"}`);
  const log = console.log;
  try {
    console.log = () => {};
    try {
      edit(cb, [name], file, true);
    } finally {
      console.log = log;
    }
    if (kept && imports.length)
      throw new UsageError(`${name} is a file kept as written: it has no imports to add to`);
    const before = fs.readFileSync(file, "utf8");
    // (the lines above the view's: the file's namespace, or the page's path)
    const head = before
      .split("\n")
      .findIndex((l) => !/^(?:\/\/ tcm: namespace |<!-- tcm: path )/.test(l));
    const patched = applyPatch(before, edits, all, -head, kept ? undefined : path.basename(file));
    if ("problem" in patched)
      throw new UsageError(
        `${name}: ${patched.problem}; nothing was changed (\`tcm view ${name}\` shows its code)`,
      );
    const r = {
      text: imports.length
        ? patched.text
            .split("\n")
            .toSpliced(head, 0, ...patchImportLines(imports))
            .join("\n")
        : patched.text,
    };
    if (r.text === before) {
      console.log(dim(`${name}: no change`));
      return true;
    }
    fs.writeFileSync(file, r.text);
    // names the new code uses that the definition didn't import: imported from where the codebase has them (if it
    // has a name the old code didn't: finding out takes a typecheck)
    const words = (s: string) => new Set(s.match(/[A-Za-z_$][\w$]*/g) ?? []);
    const had = words(before);
    const added = [...words(r.text)].some((w) => !had.has(w));
    // what the checker says of the file, said of the definition (the file is gone after), as it is then (imports
    // added to it are lines above the definition)
    const at = () =>
      kept
        ? (s: string) => s
        : patchLocations(cb, file, fs.readFileSync(file, "utf8"), [name], before);
    if (!kept && added)
      for (const line of rewritingOutput(at(), () => importMissing(cb, file, name)))
        console.log(dim(`  ${line}`));
    // (what it used that's replaced: the current one, as `tcm edit` says)
    if (!kept)
      for (const n of replacedUses(
        cb,
        cb.lookup(name).map((h) => cb.store.getDef(h)!),
      ).notes)
        console.log(dim(`  ${n}`));
    console.log(dim(`${name}:`));
    for (const [op, line] of lineDiff(before.split("\n"), r.text.split("\n"))) {
      if (op === "-") console.log(red(`  - ${line}`));
      else if (op === "+") console.log(green(`  + ${line}`));
    }
    for (const n of patched.notes ?? []) console.log(yellow(`note: ${n}`));
    console.log();
    return kept
      ? save(cb, file, "update", ns)
      : rewritingOutput(at(), () => save(cb, file, "update"));
  } finally {
    fs.rmSync(dir ?? file, { recursive: true, force: true });
  }
}

uses

used by