saveScratch
uses node:fs
Keep text as the scratch file file (when it's changed), and check it, in this process, as the language server
does as you type: TypeScript's problems and tcm's, each where it is (a name the codebase has, with the import that
fixes it), oxlint's as warnings, and, if there are none, what updating the
codebase with it would do. A file kept as written (Markdown, say) isn't code: only what updating would do.
import { lintScratch } from "#codebase/tcmts/cli/lint"; import { classify } from "#codebase/tcmts/cli/main"; import type { Analysis } from "#codebase/tcmts/core/analyze"; import { analyzedScratch, editableCodebase, importEdit, keptCheck, keptFile, scratchPath, } from "#codebase/tcmts/studio"; import type { ScratchCheck, ScratchProblem } from "#codebase/tcmts/studio"; import fs from "node:fs"; import path from "node:path"; import { stripVTControlCharacters } from "node:util"; export async function saveScratch(file: string, text: string): Promise<ScratchCheck> { const cb = editableCodebase(); const abs = scratchPath(cb, file, text); if (!fs.existsSync(abs) || fs.readFileSync(abs, "utf8") !== text) fs.writeFileSync(abs, text); // a file kept as written: there's no code to check, only what it'd change const kept = /\.tsx?$/.test(file) ? undefined : keptFile(cb, abs, text); if (kept) return keptCheck(cb, kept, abs, text); const problems: ScratchProblem[] = []; // (`file:12:5 - error TS2322: …`, TypeScript's; tcm's are `file:12:5: …`) const where = (said: string, message: string, severity: ScratchProblem["severity"]) => { const head = stripVTControlCharacters(said).split("\n")[0]!; const m = /^(.*?):(\d+):(\d+)(?: - |: )/.exec(head); if (m && path.basename(m[1]!) === file) return problems.push({ line: Number(m[2]), col: Number(m[3]), message, severity }); problems.push({ line: 1, col: 1, message: m ? `${m[1]}:${m[2]}:${m[3]}: ${message}` : message, severity, }); }; let a: Analysis; try { a = analyzedScratch(cb, abs, text); } catch (e) { return { problems: [{ line: 1, col: 1, message: (e as Error).message, severity: "error" }], rows: [], }; } for (const d of a.diagnostics) where(d.formatted, d.message, "error"); // a name that isn't there ("Cannot find name 'x'", and "… Did you mean"): imported, if the codebase has it a.diagnostics.forEach((d, i) => { if (d.code !== 2304 && d.code !== 2552) return; const name = /'([^']+)'/.exec(d.message)?.[1]; if (!name) return; const fixes = cb.store .names() .filter((n) => n.path.slice(n.path.lastIndexOf(".") + 1) === name) .slice(0, 6) .flatMap((n) => { const ns = n.path.slice(0, Math.max(0, n.path.lastIndexOf("."))); const module = ns ? `#codebase/${ns.replaceAll(".", "/")}` : "#codebase"; const edit = importEdit(text, module, name, n.kind === "type"); return edit ? [{ label: `Import ${name} from "${module}"`, changes: [edit] }] : []; }); if (fixes.length) problems[i]!.fixes = fixes; }); for (const e of a.errors) { const m = /^(?:.*?):\d+:\d+: ([\s\S]*)$/.exec(e); where(e, m ? m[1]! : e, "error"); } for (const w of a.warnings) where(w, w, "warning"); // lint, as written, type errors or not (it's about the code, not whether it checks): said, never refusing an update try { const starts = [0]; for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1); // (where TypeScript says something already, lint's isn't said again: a conflict marker, say) const said = new Set( problems.filter((p) => p.severity === "error").map((p) => `${p.line}:${p.col}`), ); for (const l of lintScratch(cb, abs, text)) { let line = starts.findLastIndex((s) => s <= l.start); if (line < 0) line = 0; if (said.has(`${line + 1}:${l.start - starts[line]! + 1}`)) continue; problems.push({ line: line + 1, col: l.start - starts[line]! + 1, length: Math.max(1, l.end - l.start), message: l.help ? `${l.message} (${l.help})` : l.message, severity: "warning", source: l.rule, }); } } catch { // (oxlint not there to run: TypeScript's problems are the ones said) } if (problems.some((p) => p.severity === "error")) return { problems, rows: [], ...(a.namespace ? { namespace: a.namespace } : {}) }; try { const rows = classify(cb, a.defs, a.namespace ?? "").map((r) => ({ path: r.path, status: r.status, ...(r.note ? { note: r.note } : {}), kind: r.def.kind, ...(r.retyped ? { retyped: true } : {}), ...(() => { if (r.status !== "update") return {}; const was = cb.lookup(r.path)[0]; const usedBy = was ? [ ...new Set( cb.store.namedDependents(was).flatMap((d) => cb.store.pathsOf(d).slice(0, 1)), ), ] .sort() .slice(0, 30) : []; return usedBy.length ? { usedBy } : {}; })(), })); return { problems, rows, ...(a.namespace ? { namespace: a.namespace } : {}) }; } catch (e) { return { problems: [ ...problems, { line: 1, col: 1, message: (e as Error).message, severity: "error" }, ], rows: [], }; } }
uses
- tcmts.cli.lint.lintScratch
- tcmts.cli.main.classify
- tcmts.core.analyze.Analysis
- tcmts.studio.ScratchCheck
- tcmts.studio.ScratchProblem
- tcmts.studio.analyzedScratch
- tcmts.studio.editableCodebase
- tcmts.studio.importEdit
- tcmts.studio.keptCheck
- tcmts.studio.keptFile
- tcmts.studio.scratchPath