edit

tcmts.cli.main.edit · #2iift7mi · term · runs on the server

uses node:fs

import type { Codebase } from "#codebase/tcmts/cli/codebase";
import {
  addToScratch, clearSaved, declarationOrder, editFiles, fileBindings, holdsOnlySaved, replacedUses,
} from "#codebase/tcmts/cli/main";
import { conflictAt, editConflict, unsavedIn } from "#codebase/tcmts/cli/merge";
import { joinName } from "#codebase/tcmts/cli/mirror";
import { dim } from "#codebase/tcmts/cli/style";
import { shownPath, UsageError } from "#codebase/tcmts/cli/ui";
import { materialize, render } from "#codebase/tcmts/loader";
import fs from "node:fs";
import path_2 from "node:path";

export function edit(
  cb: Codebase,
  targets: string[],
  target: string | ((jsx: boolean) => string),
  force: boolean,
) {
  // a conflicted name: its definitions merged three-way, with conflict markers where they overlap
  const conflicted = targets.flatMap((t) => {
    const c = !t.startsWith("#") && conflictAt(cb, t);
    return c ? [c] : [];
  });
  // (several: in one file, so one using another typechecks with it as it's settled)
  if (conflicted.length) {
    if (conflicted.length < targets.length)
      throw new UsageError(
        `${conflicted[0]!.path} is conflicted: edit it on its own, or with other conflicted names (its file holds its definitions merged)`,
      );
    return editConflict(cb, conflicted, target, force);
  }
  // files kept as written are edited as the files they are
  if (
    targets.some((t) =>
      (t.startsWith("#") ? [cb.lookupOne(t)] : cb.lookup(t)).some(
        (h) => cb.store.getDef(h)?.kind === "file",
      ),
    )
  ) {
    return editFiles(cb, targets, typeof target === "string" ? target : undefined, force);
  }
  // JSX only parses in a .tsx file: the default scratch file follows what's being edited
  const jsx = targets.some((t) =>
    (t.startsWith("#") ? [cb.lookupOne(t)] : cb.lookup(t)).some((h) => cb.store.getDef(h)?.jsx),
  );
  const work = (f: string) => fs.existsSync(f) && !!fs.readFileSync(f, "utf8").trim();
  // (a .tsx scratch file holding work takes code without JSX too: one file for all of it)
  const file =
    typeof target === "string"
      ? target
      : !jsx && work(target(true)) && !work(target(false))
        ? target(true)
        : target(jsx);
  const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
  // a scratch file holding work keeps it, and what's edited now is added to it; one holding only what's saved has
  // nothing to lose, and starts again (as --force does), even where that's a side of a conflict now
  const adding =
    !!existing.trim() &&
    !force &&
    !holdsOnlySaved(cb, file) &&
    unsavedIn(cb, file, existing).length > 0;
  const fileNs = adding ? (/^\/\/ tcm: namespace (\S+)/m.exec(existing)?.[1] ?? "") : "";
  // what's in it saved as it is goes: the file holds what's being worked on
  const { text: kept, cleared } = adding
    ? clearSaved(cb, file, existing)
    : { text: existing, cleared: [] as string[] };
  const pathOf = new Map<string, string | undefined>();
  for (const t of targets) {
    // a name holding a type and a value edits both
    const hs = t.startsWith("#") ? [cb.lookupOne(t)] : cb.lookup(t);
    if (!hs.length) throw new UsageError(cb.notFound(t));
    for (const h of hs)
      if (!pathOf.has(h)) pathOf.set(h, t.startsWith("#") ? cb.store.pathsOf(h)[0] : t);
  }
  // definitions that refer to each other are hashed as one: saving changes to some saves the rest with them, as
  // they are, so the file holds only what was asked for
  const cycle = new Set<string>();
  for (const h of pathOf.keys()) {
    for (const m of cb.store.componentMembers(cb.store.getDef(h)!.componentHash)) {
      const p = cb.store.pathsOf(m.hash)[0];
      if (!pathOf.has(m.hash) && p) cycle.add(p);
    }
  }
  const nsOf = (p: string) => (p.includes(".") ? p.slice(0, p.lastIndexOf(".")) : "");
  // already in the file it's added to: there once is enough
  const inFile = (p: string) => {
    const leaf = p.slice(p.lastIndexOf(".") + 1).replaceAll("$", "\\$");
    const declared = new RegExp(
      `^(?:export\\s+)?(?:declare\\s+)?(?:async\\s+)?(?:abstract\\s+)?(?:const|let|var|function\\*?|class|type|interface)\\s+${leaf}\\b`,
      "m",
    );
    return kept.includes(`// tcm: path ${p}\n`) || (nsOf(p) === fileNs && declared.test(kept));
  };
  if (adding) for (const [h, p] of pathOf) if (p !== undefined && inFile(p)) pathOf.delete(h);
  if (!pathOf.size) {
    console.log(
      `${targets.join(", ")} ${targets.length > 1 ? "are" : "is"} in ${shownPath(file)} already`,
    );
    return;
  }
  const nss = new Set([...pathOf.values()].filter((p) => p !== undefined).map(nsOf));
  // (added to a file, the definitions say where they go unless it's the file's namespace)
  const ns = adding ? fileNs : nss.size === 1 ? [...nss][0]! : "";
  // (each after what its value uses: a file declares them in an order that typechecks)
  const recs = declarationOrder(
    [...pathOf.keys()].map((h) => cb.store.getDef(h)!),
    (r) => [r],
  );
  const renderWith = (avoid: string[]) =>
    render(recs, cb.source(), cb.lay, {
      style: "scratch",
      // as you'd write it in a scratch file (export doesn't change a definition's hash)
      exportDeclarations: true,
      // references to replaced definitions point at their replacements, so a todo item is edited against the new code
      remap: (h) => cb.store.patched(h),
      dir: path_2.dirname(path_2.resolve(file)),
      // a definition whose path isn't `<namespace>.<declared name>` (other namespace, or renamed while keeping
      // its runtime name) says where it goes, so `update` binds it back to the same path
      annotate: (h, declaredAs) => {
        const p = pathOf.get(h);
        return p && p !== joinName(ns, declaredAs) ? `// tcm: path ${p}` : undefined;
      },
      avoid,
    });
  // added to a file, a name it has for something else is given another here (`find_1`), rather than taken to mean
  // what the file means by it (saved, each reads as it does alone: see `spelledAlone`)
  const ours = adding ? fileBindings(file, kept, fileNs) : new Map<string, string>();
  let rendered = renderWith([]);
  const clashing = [...fileBindings(file, rendered.text, ns)]
    .filter(([n, b]) => ours.has(n) && ours.get(n) !== b)
    .map(([n]) => n);
  if (clashing.length) rendered = renderWith(clashing);
  // a function's or class's name is part of its hash: written with another's, or another a definition here or in
  // the file is declared as, it would be another definition
  for (const r of recs) {
    if (!r.nominal) continue;
    const as = rendered.memberNames.get(r.hash);
    const theirs = ours.get(r.nameHint);
    if (as === r.nameHint && (theirs === undefined || theirs === `path:${pathOf.get(r.hash)}`))
      continue;
    throw new UsageError(
      `${pathOf.get(r.hash) ?? `#${r.hash}`} can't be written ${adding ? `into ${shownPath(file)}` : "with the others"}: another definition there is declared ${r.nameHint} too, and a function's or class's name is part of its hash (written as another, it would be another definition). Edit it in a file of its own: \`tcm edit ${pathOf.get(r.hash) ?? `#${r.hash}`} --file other.ts\` (tcm_edit's \`file\`)`,
    );
  }
  const text = rendered.text;
  // what nothing names is imported as what replaced it, or by its hash (from the cache, where it must be)
  const used = replacedUses(cb, recs);
  if (used.byHash.length) materialize(used.byHash, cb.source(), cb.lay, { keepExisting: true });
  if (adding) {
    fs.writeFileSync(file, addToScratch(file, kept, text));
    console.log(
      `added ${targets.join(", ")} to ${shownPath(file)}${cleared.length ? dim(` (took out ${cleared.join(", ")}: saved as ${cleared.length > 1 ? "they are" : "it is"})`) : ""}`,
    );
  } else {
    fs.writeFileSync(file, (ns ? `// tcm: namespace ${ns}\n` : "") + text);
    // (where it is from here: a session's is in its own directory)
    console.log(`wrote ${targets.join(", ")} to ${shownPath(file)}`);
  }
  for (const n of used.notes) console.log(dim(`  ${n}`));
  if (cycle.size) {
    const them = [...cycle];
    console.log(
      dim(
        `  in a cycle with ${them.slice(0, 5).join(", ")}${them.length > 5 ? `, and ${them.length - 5} more` : ""}: saving ${targets.length > 1 ? "them" : "it"} saves ${them.length > 1 ? "those" : "that"} too, as ${them.length > 1 ? "they are" : "it is"} (\`tcm edit\` ${them.length > 1 ? "them" : "it"} to change ${them.length > 1 ? "them" : "it"} too)`,
      ),
    );
  }
}

uses

used by