edit
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
- tcmts.cli.codebase.Codebase
- tcmts.cli.main.addToScratch
- tcmts.cli.main.clearSaved
- tcmts.cli.main.declarationOrder
- tcmts.cli.main.editFiles
- tcmts.cli.main.fileBindings
- tcmts.cli.main.holdsOnlySaved
- tcmts.cli.main.replacedUses
- tcmts.cli.merge.conflictAt
- tcmts.cli.merge.editConflict
- tcmts.cli.merge.unsavedIn
- tcmts.cli.mirror.joinName
- tcmts.cli.style.dim
- tcmts.cli.ui.UsageError
- tcmts.cli.ui.shownPath
- tcmts.loader.materialize
- tcmts.loader.render