syncMirror

tcmts.cli.mirror.syncMirror · #tjrrx3ze · term · runs on the server

uses node:path

Bring the mirror's files up to date with the codebase, if this codebase has one and its names changed. Returns whether the files now match.

import type { CodebaseCore } from "#codebase/tcmts/cli/codebase";
import { fileContent, fileLeaf, fileName, filesIn } from "#codebase/tcmts/cli/files";
import {
  applyEdits, checkMirror, codeFor, declarations, docComment, fileFor, followFileRename, followMoves, importedNames,
  importsAt, isAsset, joinName, loadMirror, namesIn, nearbyFiles, onMirrorBranch, pruneImports, renameEverywhere,
  saveMirror, sha, Spelling, terminated, untextual, valueImports,
} from "#codebase/tcmts/cli/mirror";
import type { Edit, Move } from "#codebase/tcmts/cli/mirror";
import { dim, red, yellow } from "#codebase/tcmts/cli/style";
import type { DefRecord } from "#codebase/tcmts/core/types";
import fs from "node:fs";
import path_1 from "node:path";

export function syncMirror(cb: CodebaseCore): boolean {
  const m = loadMirror(cb);
  if (!m || !onMirrorBranch(cb, m) || cb.store.logHead() === m.log) return true;
  const abs = (rel: string) => path_1.join(m.dir, rel);
  // never overwrite a hand edit: until the file is imported again, the mirror waits
  const handEdited = Object.entries(m.files).filter(
    ([rel, f]) => !fs.existsSync(abs(rel)) || sha(fs.readFileSync(abs(rel), "utf8")) !== f.sha,
  );
  if (handEdited.length) {
    console.log(
      yellow(
        `mirror: ${handEdited
          .map(([rel]) => rel)
          .slice(0, 3)
          .join(
            ", ",
          )}${handEdited.length > 3 ? ", …" : ""} changed outside tcm and ${handEdited.length > 1 ? "don't" : "doesn't"} import yet; the codebase's changes are written to the files once they do (at the next command)`,
      ),
    );
    return false;
  }
  const untouched = untextual(cb, m.log);
  // the files of code (a namespace each); the rest (Markdown, CSS, images) are files of the codebase, below
  const code = () => Object.entries(m.files).filter(([rel]) => !isAsset(rel, m.dir));
  const byNs = new Map(code().map(([rel, f]) => [f.ns, rel]));
  const now = new Map<string, Record<string, string>>();
  for (const [, f] of code()) now.set(f.ns, namesIn(cb, f.ns));
  const problems: string[] = [];
  // a definition in a namespace no file is for yet: its file is made, where the namespace says it goes
  const created: string[] = [];
  for (const n of cb.store.names()) {
    if (!n.path.startsWith(m.into ? `${m.into}.` : "")) continue;
    const ns = n.path.slice(0, n.path.lastIndexOf("."));
    if (byNs.has(ns) || [...byNs.keys()].some((k) => k.startsWith(`${ns}.`))) continue;
    const names = namesIn(cb, ns);
    const rel = fileFor(
      m,
      ns,
      Object.values(names).some((h) => cb.store.getDef(h)?.jsx),
    );
    if (!rel) {
      problems.push(
        `${n.path} is in ${ns}, and there's no file it could be in that the mirror would read as ${ns}; create one, then \`tcm import --update\``,
      );
      continue;
    }
    fs.mkdirSync(path_1.dirname(abs(rel)), { recursive: true });
    fs.writeFileSync(abs(rel), "");
    m.files[rel] = { ns, sha: sha(""), names: {} };
    byNs.set(ns, rel);
    now.set(ns, names);
    created.push(rel);
  }
  // JSX comes to a namespace whose file is .ts, where it can't parse: the file becomes .tsx, and imports of it follow
  const renamedFiles: { from: string; to: string }[] = [];
  for (const [rel, f] of code()) {
    if (
      !/(?<!\.d)\.ts$/.test(rel) ||
      !Object.values(now.get(f.ns)!).some((h) => cb.store.getDef(h)?.jsx)
    )
      continue;
    const to = `${rel}x`;
    if (fs.existsSync(abs(to))) {
      problems.push(`${f.ns} has JSX now, which ${rel} can't hold, and ${to} is there already`);
      continue;
    }
    fs.renameSync(abs(rel), abs(to));
    m.files[to] = f;
    delete m.files[rel];
    byNs.set(f.ns, to);
    renamedFiles.push({ from: rel, to });
  }
  // where each definition of the mirror lives now, for references to it
  const home = new Map<string, { file: string; ns: string; leaf: string }>();
  for (const [ns, names] of now)
    for (const [leaf, h] of Object.entries(names))
      home.set(h, { file: abs(byNs.get(ns)!), ns, leaf });
  const before = new Map(code().map(([rel]) => [rel, fs.readFileSync(abs(rel), "utf8")]));
  // what each definition the files hold was called when tcm last wrote them
  const named = new Map<string, string>();
  for (const [, f] of code())
    for (const [leaf, h] of Object.entries(f.names)) named.set(h, joinName(f.ns, leaf));
  const renames: { rel: string; from: string; to: string }[] = [];
  // definitions gone from one file's namespace to another's: by hash, where from and what they were called there
  const moves: Move[] = [];
  // what the files written import from the project's others
  const imported: { file: string; leaf: string }[] = [];
  const moved = new Map<string, { file: string; leaf: string }>();
  for (const [rel, f] of code()) {
    const cur = new Set(Object.values(now.get(f.ns)!));
    for (const [leaf, h] of Object.entries(f.names))
      if (!cur.has(h) && home.has(h) && home.get(h)!.file !== abs(rel))
        moved.set(h, { file: abs(rel), leaf });
  }

  for (const [rel, f] of code()) {
    const text = before.get(rel)!;
    const cur = now.get(f.ns)!;
    const decls = declarations(abs(rel), text);
    const curByHash = new Map(Object.entries(cur).map(([l, h]) => [h, l]));
    const edits: Edit[] = [];
    const existing = Object.values(f.names)
      .map((h) => cb.store.getDef(h))
      .filter((d): d is DefRecord => !!d);
    const spelling = new Spelling(
      cb,
      abs(rel),
      f.ns,
      existing,
      (h) => home.get(h),
      [...decls.keys(), ...Object.keys(cur), ...importedNames(abs(rel), text)],
      (h) => named.get(h),
    );
    const renamedTo = new Set<string>();
    for (const [leaf, h] of Object.entries(f.names)) {
      const d = decls.get(leaf);
      const to = curByHash.get(h);
      if (cur[leaf] === h) continue;
      // the same definition under a new name: renamed at every reference, below
      if (to && to !== leaf && !(to in f.names)) {
        renames.push({ rel, from: leaf, to });
        renamedTo.add(to);
        continue;
      }
      if (!d) continue;
      const next = cur[leaf];
      if (!next) {
        // moved to another file's namespace: imports of it follow it, below
        if (moved.has(h)) moves.push({ from: abs(rel), to: home.get(h)!.file, leaf, as: leaf });
        // gone from the namespace: its declaration and doc go too, with the blank line after
        const from = d.doc?.start ?? d.start;
        let end = d.end;
        while (text[end] === "\n" && text[end + 1] === "\n") end++;
        edits.push({ start: from, end: end + (text[end] === "\n" ? 1 : 0), text: "" });
        continue;
      }
      if (untouched.has(next)) continue;
      if (d.shared) {
        problems.push(
          `${joinName(f.ns, leaf)} is declared alongside others in ${rel}; change it there by hand`,
        );
        continue;
      }
      const rec = cb.store.getDef(next)!;
      try {
        edits.push({
          start: d.start,
          end: d.end,
          text: terminated(
            codeFor(rec, leaf, spelling, d.exported),
            text.slice(d.start, d.end).endsWith(";"),
          ),
        });
        const was = cb.store.getDef(h)?.doc;
        if ((rec.doc ?? "") !== (was ?? "")) {
          if (d.doc)
            edits.push({
              start: d.doc.start,
              end: d.start,
              text: rec.doc ? docComment(rec.doc) : "",
            });
          else if (rec.doc) edits.push({ start: d.start, end: d.start, text: docComment(rec.doc) });
        }
      } catch (e) {
        problems.push(`${joinName(f.ns, leaf)}: ${(e as Error).message}`);
      }
    }
    // new in the namespace: added at the end of its file
    let added = "";
    for (const [leaf, h] of Object.entries(cur)) {
      if (leaf in f.names || renamedTo.has(leaf)) continue;
      const rec = cb.store.getDef(h)!;
      // moved here and renamed: written as it was called, then renamed at every reference like any rename
      const was = moved.get(h)?.leaf;
      if (was && was !== leaf) {
        renames.push({ rel, from: was, to: leaf });
        renamedTo.add(leaf);
      }
      try {
        const code = codeFor(rec, was ?? leaf, spelling, true);
        const written = `${rec.doc ? docComment(rec.doc) : ""}${terminated(code, /^(export )?(const|let|var|type) /.test(code))}\n`;
        // before the first declaration here that uses it (a value is only there once its declaration has run),
        // else at the end
        const users = [...decls]
          .filter(([l]) => cur[l] && cb.store.getDef(cur[l]!)?.deps.some((d) => d.ref === `#${h}`))
          .map(([, d]) => d.doc?.start ?? d.start);
        if (users.length)
          edits.push({ start: Math.min(...users), end: Math.min(...users), text: `${written}\n` });
        else added += `\n${written}`;
      } catch (e) {
        problems.push(`${joinName(f.ns, leaf)}: ${(e as Error).message}`);
      }
    }
    // a new file starts with its imports, if any, then its code
    if (added)
      edits.push({
        start: text.length,
        end: text.length,
        text: !text
          ? spelling.imports.length
            ? added
            : added.replace(/^\n/, "")
          : (text.endsWith("\n") ? "" : "\n") + added,
      });
    if (spelling.imports.length) {
      const at = importsAt(abs(rel), text);
      edits.push({
        start: at,
        end: at,
        text: (at ? "\n" : "") + spelling.imports.join("\n") + (at ? "" : "\n"),
      });
    }
    if (edits.length)
      fs.writeFileSync(
        abs(rel),
        valueImports(
          abs(rel),
          pruneImports(abs(rel), text, applyEdits(text, edits)),
          spelling.promoted,
        ),
      );
    imported.push(...spelling.importedFrom);
  }
  // in the codebase anything can be imported; in the files, what's imported from another is exported there
  for (const file of new Set(imported.map((i) => i.file))) {
    const text = fs.readFileSync(file, "utf8");
    const decls = declarations(file, text);
    const edits: Edit[] = [];
    for (const leaf of new Set(imported.filter((i) => i.file === file).map((i) => i.leaf))) {
      const d = decls.get(leaf);
      if (d && !d.exported && !edits.some((e) => e.start === d.start))
        edits.push({ start: d.start, end: d.start, text: "export " });
    }
    if (edits.length) fs.writeFileSync(file, applyEdits(text, edits));
  }
  // files of the codebase that aren't code: written as it has them now, removed with them, made for new ones
  const assetsBefore = new Map<string, Buffer>();
  const createdAssets: string[] = [];
  const recorded = new Set<string>();
  for (const [rel, f] of Object.entries(m.files)) {
    if (!isAsset(rel, m.dir)) continue;
    const name = joinName(f.ns, fileLeaf(rel));
    recorded.add(name);
    const h = cb.store.hashesAt(name).find((x) => cb.store.getDef(x)?.kind === "file");
    const was = fs.readFileSync(abs(rel));
    // (the same file reads differently when what it names was renamed)
    const content = h && fileContent(cb, cb.store.getDef(h)!, f.ns);
    if (content && content.equals(was)) continue;
    assetsBefore.set(rel, was);
    if (content) fs.writeFileSync(abs(rel), content);
    else fs.rmSync(abs(rel), { force: true });
  }
  for (const [name, rec] of filesIn(cb, m.into)) {
    if (recorded.has(name)) continue;
    const rest = (m.into ? name.slice(m.into.length + 1) : name).split(".");
    const rel = path_1.join(...rest.slice(0, -1), fileName(rest.at(-1)!, rec));
    if (fs.existsSync(abs(rel))) {
      problems.push(`${name} would be written to ${rel}, which is there already`);
      continue;
    }
    fs.mkdirSync(path_1.dirname(abs(rel)), { recursive: true });
    fs.writeFileSync(abs(rel), fileContent(cb, rec, name.slice(0, name.lastIndexOf("."))));
    m.files[rel] = { ns: name.slice(0, name.lastIndexOf(".")), sha: "", names: {} };
    createdAssets.push(rel);
  }
  // files outside the mirror that a rename reached, as they were
  let outside = new Map<string, string>();
  for (const r of renamedFiles) {
    const files = [
      ...Object.keys(m.files)
        .filter((rel) => !isAsset(rel, m.dir))
        .map(abs),
      ...nearbyFiles(m, [path_1.basename(r.from)]),
    ];
    for (const [file, text] of followFileRename(
      files,
      new Set(Object.keys(m.files).map(abs)),
      abs(r.from),
      abs(r.to),
    ))
      if (!outside.has(file)) outside.set(file, text);
  }
  if (moves.length && !problems.length) {
    const files = [
      ...Object.keys(m.files).map(abs),
      ...nearbyFiles(
        m,
        moves.map((v) => v.leaf),
      ),
    ];
    outside = followMoves(files, new Set(Object.keys(m.files).map(abs)), moves, problems);
  }
  if (renames.length && !problems.length) {
    try {
      for (const [file, text] of renameEverywhere(cb, m, renames))
        if (!outside.has(file)) outside.set(file, text);
    } catch (e) {
      problems.push(`renaming: ${(e as Error).message}`);
    }
  }

  const restore = () => {
    for (const [rel, text] of before) if (!created.includes(rel)) fs.writeFileSync(abs(rel), text);
    for (const rel of created) fs.rmSync(abs(rel), { force: true });
    for (const [file, text] of outside) fs.writeFileSync(file, text);
    for (const [rel, bytes] of assetsBefore) fs.writeFileSync(abs(rel), bytes);
    for (const r of renamedFiles) {
      fs.renameSync(abs(r.to), abs(r.from));
      m.files[r.from] = m.files[r.to]!;
      delete m.files[r.to];
    }
    for (const rel of createdAssets) {
      fs.rmSync(abs(rel), { force: true });
      delete m.files[rel];
    }
  };
  // nothing written (the codebase's changes left the text alone): the files are as they were
  const wrote =
    created.length > 0 ||
    renamedFiles.length > 0 ||
    outside.size > 0 ||
    assetsBefore.size > 0 ||
    createdAssets.length > 0 ||
    [...before].some(([rel, text]) => fs.readFileSync(abs(rel), "utf8") !== text);
  if (!problems.length && wrote) {
    // the files must hash to what the codebase holds; a renamed function or class doesn't until the codebase takes
    // the renamed code
    const mismatch = checkMirror(cb, m, true);
    if (mismatch.length && renames.length && cb.adoptMirror) {
      console.log(
        dim(
          `mirror: ${renames.map((r) => `${r.from} → ${r.to}`).join(", ")} renamed in the code too; importing it`,
        ),
      );
      if (cb.adoptMirror(m) && !checkMirror(cb, loadMirror(cb) ?? m, true).length) return true;
      problems.push("importing the renamed code didn't bring the files and the codebase together");
    } else if (mismatch.length) problems.push(...mismatch);
  }
  if (problems.length) {
    restore();
    console.log(
      red(
        `mirror: couldn't write the codebase's changes to ${path_1.relative(process.cwd(), m.dir) || "."}; the files are as they were`,
      ),
    );
    for (const p of problems.slice(0, 10)) console.log(red(`  ${p}`));
    return false;
  }
  const changed = [
    ...renamedFiles.map((r) => `${r.from} (now ${r.to})`),
    ...[...before]
      .filter(([rel, text]) => fs.readFileSync(abs(rel), "utf8") !== text)
      .map(([rel]) => rel),
    ...assetsBefore.keys(),
    ...createdAssets,
  ];
  if (changed.length)
    console.log(
      dim(
        `mirror: wrote ${changed.join(", ")}${outside.size ? ` (and renamed in ${[...outside.keys()].map((f) => path_1.relative(m.dir, f)).join(", ")}, outside it)` : ""}`,
      ),
    );
  for (const [rel, f] of Object.entries(m.files)) {
    if (!isAsset(rel, m.dir)) {
      m.files[rel] = { ...f, sha: sha(fs.readFileSync(abs(rel), "utf8")), names: now.get(f.ns)! };
      continue;
    }
    if (!fs.existsSync(abs(rel))) {
      delete m.files[rel];
      continue;
    }
    const h = cb.store
      .hashesAt(joinName(f.ns, fileLeaf(rel)))
      .find((x) => cb.store.getDef(x)?.kind === "file");
    m.files[rel] = {
      ...f,
      sha: sha(fs.readFileSync(abs(rel), "utf8")),
      names: h ? { [fileLeaf(rel)]: h } : {},
    };
  }
  m.log = cb.store.logHead();
  saveMirror(cb, m);
  return true;
}

uses

used by