syncMirror
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
- tcmts.cli.codebase.CodebaseCore
- tcmts.cli.files.fileContent
- tcmts.cli.files.fileLeaf
- tcmts.cli.files.fileName
- tcmts.cli.files.filesIn
- tcmts.cli.mirror.Edit
- tcmts.cli.mirror.Move
- tcmts.cli.mirror.Spelling
- tcmts.cli.mirror.applyEdits
- tcmts.cli.mirror.checkMirror
- tcmts.cli.mirror.codeFor
- tcmts.cli.mirror.declarations
- tcmts.cli.mirror.docComment
- tcmts.cli.mirror.fileFor
- tcmts.cli.mirror.followFileRename
- tcmts.cli.mirror.followMoves
- tcmts.cli.mirror.importedNames
- tcmts.cli.mirror.importsAt
- tcmts.cli.mirror.isAsset
- tcmts.cli.mirror.joinName
- tcmts.cli.mirror.loadMirror
- tcmts.cli.mirror.namesIn
- tcmts.cli.mirror.nearbyFiles
- tcmts.cli.mirror.onMirrorBranch
- tcmts.cli.mirror.pruneImports
- tcmts.cli.mirror.renameEverywhere
- tcmts.cli.mirror.saveMirror
- tcmts.cli.mirror.sha
- tcmts.cli.mirror.terminated
- tcmts.cli.mirror.untextual
- tcmts.cli.mirror.valueImports
- tcmts.cli.style.dim
- tcmts.cli.style.red
- tcmts.cli.style.yellow
- tcmts.core.types.DefRecord