serveLsp
uses node:util
import { branchChanges, branchList, entryChanges, historyList, namesFrom } from "#codebase/tcmts/cli/changes"; import type { NamesFrom } from "#codebase/tcmts/cli/changes"; import type { Codebase } from "#codebase/tcmts/cli/codebase"; import { decode } from "#codebase/tcmts/cli/codec"; import { author } from "#codebase/tcmts/cli/contributions"; import { renderDoc } from "#codebase/tcmts/cli/docs"; import { reaches } from "#codebase/tcmts/cli/effects"; import { formatText } from "#codebase/tcmts/cli/fmt"; import { lintScratch } from "#codebase/tcmts/cli/lint"; import { connect, lines as lines_1, plain as plain_1 } from "#codebase/tcmts/cli/lsp"; import type { DocState, Json, LspTools, Message, Position, Row } from "#codebase/tcmts/cli/lsp"; import { joinName } from "#codebase/tcmts/cli/mirror"; import { packageList, packagePage, packageUsers } from "#codebase/tcmts/cli/package_pages"; import { definitionPage, docSummary, find as find_1, isApp, isSchedule, roleOf, searchCode, } from "#codebase/tcmts/cli/pages"; import { Placement } from "#codebase/tcmts/cli/placement"; import { publishingList, releaseCheck, releaseList, releasePage } from "#codebase/tcmts/cli/release_pages"; import { changelog, contributionsOn, generatedNotes, packPackage, planRelease, reasonFor, releasePackage, writeDraft, } from "#codebase/tcmts/cli/releases"; import { codebaseRoles } from "#codebase/tcmts/cli/roles"; import { searchByType } from "#codebase/tcmts/cli/search"; import { evaluateCached, isTestPath, runCached } from "#codebase/tcmts/cli/testing"; import { upgradeTo, upgradeWithin } from "#codebase/tcmts/cli/upgrade"; import { ensureView, hashesOfViewFile, srcDir, viewText } from "#codebase/tcmts/cli/views"; import type { View } from "#codebase/tcmts/cli/views"; import { keysForResults, prepareTestRun } from "#codebase/tcmts/cli/vitest"; import type { Analysis } from "#codebase/tcmts/core/analyze"; import { shortHash } from "#codebase/tcmts/core/hash"; import { schemaText } from "#codebase/tcmts/core/schema"; import { cacheRefOf } from "#codebase/tcmts/core/shape"; import { forEachChild, parse } from "#codebase/tcmts/core/ts"; import type { DefRecord } from "#codebase/tcmts/core/types"; import { nodeArgs, writeRunner } from "#codebase/tcmts/loader"; import fs from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { fileURLToPath, pathToFileURL } from "node:url"; import { format as format_1, inspect, stripVTControlCharacters } from "node:util"; import { isIdentifier as ts_isIdentifier, isImportDeclaration as ts_isImportDeclaration, isNamedImports as ts_isNamedImports, isStringLiteral as ts_isStringLiteral, } from "typescript/ast"; import type { Identifier as ts_Identifier, Node as ts_Node } from "typescript/ast"; import { SymbolFlags as ts_SymbolFlags } from "typescript/sync"; export function serveLsp(findCodebase: (dir: string) => Codebase, tools: LspTools): void { // stdout is the protocol channel: anything else printed goes to stderr console.log = (...args: unknown[]) => process.stderr.write(format_1(...args) + "\n"); let cb: Codebase | undefined; let isVsCode = false; let canRefresh = { lens: false, hints: false }; const docs = new Map<string, DocState>(); // a definition's document as a `tcm:` URI, rendered only when the editor asks for it (tcm/source) // (`?at=<entry>` or `?branch=<name>`: read with the names there, as a diff's sides are) const lazyUri = (hash: string, names: NamesFrom = {}) => { const query = names.at ? `?at=${names.at}` : names.branch ? `?branch=${encodeURIComponent(names.branch)}` : ""; return `tcm:/@${hash}${cb!.store.getDef(hash)?.jsx ? ".tsx" : ".ts"}${query}`; }; // where the codebase's code runs, worked out once per state of the names (it reads the whole codebase) let placed: { at: string; placement: Placement } | undefined; const placementNow = (): Placement => { const at = `${cb!.store.branch().name}@${cb!.store.logHead()}`; if (placed?.at !== at) placed = { at, placement: new Placement(cb!) }; return placed.placement; }; let nextId = 1; const send = connect(process.stdin, process.stdout, (m) => { try { handle(m); } catch (e) { if (m.id !== undefined && m.method) send({ id: m.id, error: { code: -32603, message: (e as Error)?.message ?? String(e) } }); else process.stderr.write(`tcm lsp: ${(e as Error)?.stack ?? e}\n`); } }); const notify = (method: string, params: unknown) => send({ method, params }); const request = (method: string, params?: unknown) => send({ id: `tcm-${nextId++}`, method, params }); const message = (type: 1 | 2 | 3, text: string) => notify("window/showMessage", { type, message: text }); /** Only top-level `.ts`/`.tsx` files of the codebase are scratch files, as for the watcher. */ const isScratch = (file: string) => !!cb && path.dirname(file) === cb.lay.root && /\.tsx?$/.test(file) && !file.endsWith(".d.ts"); const analyze = (doc: DocState, run: boolean) => { const c = cb!; const problems: { message: string; severity: 1 | 2 }[] = []; const state: NonNullable<DocState["analyzed"]> = { text: doc.text, rows: [], problems, typeErrors: 0, lint: [], watches: new Map(), tests: new Map(), }; doc.analyzed = state; // lint as written, type errors or not: it's about the code, not whether it checks try { state.lint = lintScratch(c, doc.file, doc.text); } catch (e) { problems.push({ message: `lint: ${(e as Error).message}`, severity: 2 }); } let a: Analysis; try { c.refreshShim(); a = c.analyze(doc.file, c.scratchSalts(), [], new Map([[doc.file, doc.text]])); } catch (e) { problems.push({ message: (e as Error).message, severity: 1 }); return; } state.analysis = a; for (const w of a.warnings) problems.push({ message: w, severity: 2 }); for (const e of a.errors) problems.push({ message: e, severity: 1 }); state.typeErrors = a.diagnostics.length; if (a.diagnostics.length || a.errors.length) return; try { state.rows = tools.classify(c, a.defs, a.namespace ?? "") as Row[]; } catch (e) { problems.push({ message: (e as Error).message, severity: 1 }); return; } // while typing, show what's cached; run what isn't on open and save const node = process.version; const src = c.source([...a.defs, ...a.watches]); if (run) state.watches = evaluateCached(c, a.watches, src, false); else { // (one that reaches outside, `Date.now()` say, isn't kept: it's shown when it's run) const outside = reaches( c, a.watches.map((w) => w.hash), src, ); for (const w of a.watches) { const r = outside.get(w.hash)?.some((e) => e !== "output") ? undefined : c.store.watchResult(w.hash, node); if (r) state.watches.set(w.line, { ...r, cached: true }); } } const tests = state.rows.filter((r) => r.def.kind !== "type" && isTestPath(r.path)); if (run && tests.length) { for (const o of runCached( c, tests.map((r) => ({ path: r.path, rec: r.def })), src, false, )) state.tests.set(o.path, { ...o.result, cached: o.cached }); } else { const keys = keysForResults( c, tests.map((t) => t.def.hash), src, ); for (const t of tests) { const r = c.store.testResult(t.def.hash, keys.get(t.def.hash)!); if (r) state.tests.set(t.path, { ...r, cached: true }); } } }; const fresh = (doc: DocState) => { if (doc.analyzed?.text !== doc.text) analyze(doc, false); return doc.analyzed!; }; const publish = (doc: DocState) => { const s = fresh(doc); const { pos, lineEnd } = lines_1(doc.text); const diagnostics = s.problems.map((p) => { // tcm's errors start with `file:line:col: ` const m = /^(.*?):(\d+):(\d+): ([\s\S]*)$/.exec(p.message); const here = m && path.resolve(m[1]!) === doc.file; const line = here ? Number(m[2]) - 1 : 0; const start = { line, character: here ? Number(m[3]) - 1 : 0 }; return { range: { start, end: pos(lineEnd(line)) }, severity: p.severity, source: "tcm", message: here ? m[4]! : p.message, }; }); for (const i of s.lint) { diagnostics.push({ range: { start: pos(i.start), end: pos(Math.max(i.end, i.start)) }, severity: i.severity === "error" ? 1 : 2, source: "oxlint", message: i.help ? `${i.message}\n${i.help}` : i.message, code: i.rule, ...(i.url ? { codeDescription: { href: i.url } } : {}), } as (typeof diagnostics)[number]); } notify("textDocument/publishDiagnostics", { uri: doc.uri, version: doc.version, diagnostics }); if (canRefresh.lens) request("workspace/codeLens/refresh"); if (canRefresh.hints) request("workspace/inlayHint/refresh"); }; const timers = new Map<string, NodeJS.Timeout>(); const schedule = (doc: DocState, run: boolean) => { clearTimeout(timers.get(doc.uri)); timers.set( doc.uri, setTimeout( () => { try { analyze(doc, run); publish(doc); } catch (e) { process.stderr.write(`tcm lsp: ${(e as Error)?.stack ?? e}\n`); } }, run ? 0 : 200, ), ); }; const codeLenses = (doc: DocState) => { const s = fresh(doc); const { pos } = lines_1(doc.text); const lens = (offset: number, title: string, command = "", args: unknown[] = []) => { const p = pos(offset); return { range: { start: p, end: p }, command: { title, command, arguments: args } }; }; if (s.typeErrors) return [ lens( 0, `tcm: ${s.typeErrors} type error${s.typeErrors > 1 ? "s" : ""}; nothing hashed until they're fixed`, ), ]; const placement = cb ? placementNow() : undefined; return s.rows.flatMap((r) => { const at = r.def.range.start; const short = shortHash(r.def.hash); const out = []; if (r.status === "new") out.push(lens(at, `+ new ${r.path}`), lens(at, "Add to codebase", "tcm.add", [doc.uri])); else if (r.status === "update") out.push( lens(at, `~ update ${r.path} (${r.note})`), lens(at, "Update codebase", "tcm.update", [doc.uri]), ); else if (r.status === "alias") out.push(lens(at, `& ${r.path}: ${r.note}`), lens(at, "Add name", "tcm.add", [doc.uri])); else if (r.status === "doc") out.push( lens(at, `~ ${r.path}: ${r.note}`), lens(at, "Update codebase", "tcm.update", [doc.uri]), ); else if (r.status === "conflict") out.push(lens(at, `! ${r.path} is conflicted (${r.note}): tcm todo`)); else { const where = r.def.kind === "term" ? placement?.label(r.def.hash) : undefined; out.push(lens(at, `${r.path} ${short}${where ? ` · ${where}` : ""}`)); if (isVsCode) out.push(lens(at, "Rename", "tcm.rename", [r.path])); } const t = s.tests.get(r.path); if (t) out.push( lens( at, t.ok ? `✓ passed${t.cached ? " (cached)" : ""}` : `✗ failed: ${plain_1(t.message).split("\n")[0]}`, ), ); // run it under the debugger, as it is in this buffer if ( isVsCode && r.def.kind === "term" && (isTestPath(r.path) || /^(<[^>]*>)?\(\)\s*=>/.test(r.def.signature)) ) { out.push( lens(at, "Debug", isTestPath(r.path) ? "tcm.debugTest" : "tcm.debugDefinition", [ r.path, doc.uri, ]), ); } return out; }); }; const inlayHints = (doc: DocState) => { const s = fresh(doc); const { pos, lineEnd } = lines_1(doc.text); return (s.analysis?.watches ?? []).flatMap((w) => { const r = s.watches.get(w.line); if (!r) return []; const value = plain_1(r.value).replace(/\s*\n\s*/g, " "); const label = `${r.ok ? "⧩" : "✗"} ${value.length > 120 ? value.slice(0, 117) + "…" : value}`; return [ { position: pos(lineEnd(w.line - 1)), label, paddingLeft: true, tooltip: plain_1(r.value) }, ]; }); }; /** Markdown describing a stored definition: its names, hash, signature, placement, dependents and docs. */ const describe = (hash: string, heading?: string) => { const c = cb!; const rec = c.store.getDef(hash); if (!rec) return undefined; const names = c.store.pathsOf(hash); const lines = [ `**${heading ?? (names.join(", ") || "(unnamed)")}** \`${shortHash(hash)}\``, "", "```ts", rec.kind === "type" ? `${rec.signature}` : `: ${c.store.shownSignature(rec)}`, "```", ]; if (rec.kind === "term") { const d = placementNow().describe(hash); lines.push(`${d[0]!.toUpperCase()}${d.slice(1)}.`); } const users = c.store.dependents(hash).filter((d) => c.store.pathsOf(d).length).length; lines.push( `${users} named dependent${users === 1 ? "" : "s"}${names.length > 1 ? ` · also named ${names.slice(1).join(", ")}` : ""}`, ); // the doc as it renders: current names, embeds; examples are shown (they're evaluated on the page) const doc = renderDoc(c, rec, { evaluate: false }); if (doc) lines.push("", doc.markdown); return lines.join("\n"); }; const identifierAt = (doc: DocState, offset: number): ts_Identifier | undefined => { const sf = doc.analyzed?.analysis?.program.getSourceFile(doc.file); if (!sf) return undefined; let id: ts_Identifier | undefined; const find = (n: ts_Node) => { if (offset < n.getStart(sf) || offset > n.end) return; if (ts_isIdentifier(n)) id = n; forEachChild(n, find); }; find(sf); return id; }; const hover = (doc: DocState, p: Position) => { const s = fresh(doc); const a = s.analysis; if (!a) return null; const offset = lines_1(doc.text).offset(p); // a definition's own name: what saving it would do const row = s.rows.find( (r) => r.def.range.nameStart !== undefined && offset >= r.def.range.nameStart && offset <= r.def.range.nameEnd!, ); if (row) { const stored = describe(row.def.hash, row.path); const what = { new: "new: not in the codebase yet", update: `an update (${row.note})`, alias: `an alias (${row.note})`, conflict: `conflicted (${row.note})`, identical: "in the codebase", }[row.status] ?? row.status; return { contents: { kind: "markdown", value: stored ?? `**${row.path}** \`${shortHash(row.def.hash)}\`\n\n${what}`, }, }; } // a name from the codebase: resolve it to the hash it stands for const sf = a.program.getSourceFile(doc.file); const id = identifierAt(doc, offset); if (!sf || !id) return null; const checker = a.checker; let sym = checker.getSymbolAtLocation(id); if (sym && sym.flags & ts_SymbolFlags.Alias) sym = checker.getAliasedSymbol(sym); const hash = sym && cacheRefOf(checker)(sym); const value = hash && describe(hash); return value ? { contents: { kind: "markdown", value }, range: { start: lines_1(doc.text).pos(id.getStart(sf)), end: lines_1(doc.text).pos(id.end), }, } : null; }; /** Run a CLI command in-process, collecting what it prints. */ const capture = (fn: () => boolean | void): { ok: boolean; output: string } => { const out: string[] = []; const log = console.log; console.log = (...args: unknown[]) => void out.push(plain_1(format_1(...args))); try { return { ok: fn() !== false, output: out.join("\n").trim() }; } catch (e) { return { ok: false, output: [...out, (e as Error)?.message ?? String(e)].join("\n").trim() }; } finally { console.log = log; } }; /** * The codebase changes under us too: `tcm add` in a terminal, a pull. Re-check open files then (without running * anything: they're only re-classified), and tell the client so its sidebar and views refresh. */ let changeTimer: NodeJS.Timeout | undefined; const watchCodebase = () => { try { // rows change while an app runs: tell the client (it refreshes its data view), separately from code changes let dataTimer: NodeJS.Timeout | undefined; fs.watch(cb!.lay.tcmDir, { recursive: true }, (_e, name) => { if (name && /^data[\\/]store\.sqlite/.test(name)) { clearTimeout(dataTimer); dataTimer = setTimeout(() => notify("tcm/dataChanged", {}), 500); return; } if (!name?.startsWith("codebase.sqlite")) return; clearTimeout(changeTimer); changeTimer = setTimeout(() => { placed = undefined; for (const d of docs.values()) { d.analyzed = undefined; schedule(d, false); } notify("tcm/changed", {}); }, 300); }); } catch (e) { process.stderr.write(`tcm lsp: not watching the codebase: ${(e as Error).message}\n`); } }; const refreshAll = () => { for (const d of docs.values()) { d.analyzed = undefined; schedule(d, true); } }; const execute = (command: string, args: unknown[]) => { const c = cb!; if (command === "tcm.add" || command === "tcm.update") { const doc = docs.get(String(args[0])); const file = doc?.file ?? fileURLToPath(String(args[0])); if (doc && fs.existsSync(file) && fs.readFileSync(file, "utf8") !== doc.text) { message( 2, `Save ${path.basename(file)} first: tcm ${command.slice(4)} saves what's on disk.`, ); return null; } const r = capture(() => tools.save(c, file, command === "tcm.add" ? "add" : "update")); message(r.ok ? 3 : 1, r.output || (r.ok ? "saved" : "failed")); refreshAll(); return r.ok; } if (command === "tcm.edit") { const jsx = args.some((a) => { try { return c.lookup(String(a)).some((h) => c.store.getDef(h)?.jsx); } catch { return false; } }); const file = editFile(jsx ? ".tsx" : ".ts"); const r = capture(() => tools.edit(c, args.map(String), file, false)); if (!r.ok) message(1, r.output); return r.ok ? pathToFileURL(file).href : null; } // releases: [package, version?]; a draft (tcm.releaseDraft) opens in the editor; a release's notes come from its draft, if any if (command === "tcm.release" || command === "tcm.releaseDraft" || command === "tcm.pack") { const [name, version] = args.map((a) => a === undefined || a === null || a === "" ? undefined : String(a), ); return (async () => { try { if (command === "tcm.releaseDraft") { const plan = planRelease(c, name, undefined, true); const file = writeDraft( c, plan, generatedNotes(c, plan, [ ...c.store.contributions(), ...(await contributionsOn(c, undefined)), ]), ); return pathToFileURL(file).href; } if (command === "tcm.pack") { const r = packPackage(c, name, version, undefined); message( 3, `packed ${r.pkg.name}@${r.version} into ${path.relative(c.lay.root, r.dir)}: ${r.modules} module(s)`, ); return r.dir; } const { release, plan } = await releasePackage(c, { name, version, force: false, author: author(), }); message(3, `released ${release.package}@${release.version}: ${reasonFor(plan)}`); refreshAll(); return { package: release.package, version: release.version }; } catch (e) { message(1, (e as Error).message); return null; } })(); } if (command === "tcm.upgrade") { // [name] within its ranges; [name, target, namespace?] to another range const [name, target, namespace] = args.map((a) => a === undefined || a === null ? undefined : String(a), ); const r = capture(() => target ? upgradeTo(c, name!, target, namespace) : upgradeWithin(c, name!, false), ); message(r.ok ? 3 : 1, r.output || (r.ok ? "upgraded" : "upgrade failed")); c.refreshShim(); refreshAll(); return r.ok; } if (command === "tcm.dataMigrate") { return (async () => { const out: string[] = []; const log = console.log; console.log = (...a: unknown[]) => void out.push(plain_1(format_1(...a))); let ok = false; try { ok = await tools.migrateTable(c, String(args[0])); } catch (e) { out.push((e as Error)?.message ?? String(e)); } finally { console.log = log; } message(ok ? 3 : 1, out.join("\n").trim() || (ok ? "migrated" : "failed")); notify("tcm/dataChanged", {}); return ok; })(); } // a definition added to a scratch file (its saved copy on disk: the editor saves it first) if (command === "tcm.editInto") { const r = capture(() => tools.edit(c, [String(args[1])], fileURLToPath(String(args[0])), false), ); message(r.ok ? 3 : 1, r.output.trim() || (r.ok ? "added" : "failed")); return r.ok ? String(args[0]) : null; } if (command === "tcm.mv") { const r = capture(() => tools.mv(c, String(args[0]), String(args[1]))); message(r.ok ? 3 : 1, r.output || (r.ok ? "renamed" : "failed")); refreshAll(); return r.ok; } throw new Error(`unknown command ${command}`); }; // ---- the codebase as documents ------------------------------------------------------------------------------ // A definition's document is its file in .tcm/src (`lib/square.ts` is lib.square rendered with current names, // `@<hash>.ts` an unnamed one): a real file, so debuggers can stop in it. `tcm:/lib/square.ts` names the same // view for clients that ask for it. Its references are known exactly (render reports where each lands), so they // navigate like links. const viewUri = (hash: string) => pathToFileURL(ensureView(cb!, hash)).href; /** A definition's view: its file in .tcm/src, or a tcm: document */ const isView = (uri: string) => uri.startsWith("tcm:") || (uri.startsWith("file:") && !!cb && fileURLToPath(uri).startsWith(srcDir(cb) + path.sep)); const hashesOf = (uri: string): string[] => { if (uri.startsWith("file:")) return hashesOfViewFile(cb!, fileURLToPath(uri)); const p = decodeURIComponent(uri.replace(/^tcm:\/*/, "").replace(/[?#].*$/, "")).replace( /\.tsx?$/, "", ); try { return p.startsWith("@") ? cb!.lookup(`#${p.slice(1)}`) : cb!.lookup(p.replaceAll("/", ".")); } catch { return []; } }; // the same text as the definition's file in .tcm/src const view = (uri: string): View | undefined => { const q = /[?&](at|branch)=([^&#]+)/.exec(uri); const names = q ? namesFrom(cb!, q[1] === "at" ? { at: q[2] } : { branch: decodeURIComponent(q[2]!) }) : undefined; return viewText( cb!, hashesOf(uri).filter((h) => cb!.store.getDef(h)), placementNow(), names, ); }; const span = (text: string, start: number, end: number) => { const { pos } = lines_1(text); return { start: pos(start), end: pos(end) }; }; /** Where a definition is declared in its own view: its name, or the start of its declaration. */ const declaration = (hash: string) => { const uri = viewUri(hash); const v = view(uri); if (!v) return undefined; const self = v.refs.find((r) => r.hash === hash && r.from === hash); const at = v.starts.get(hash) ?? 0; return { uri, range: self ? span(v.text, self.start, self.end) : span(v.text, at, at) }; }; /** Every place a named definition refers to `hash`, in their views. */ const usages = (hash: string) => { const c = cb!; const out: { uri: string; range: ReturnType<typeof span> }[] = []; const users = [...new Set(c.store.dependents(hash))].filter( (d) => d !== hash && c.store.pathsOf(d).length, ); const views = new Map<string, View | undefined>(); for (const d of users) { const uri = viewUri(d); if (!views.has(uri)) views.set(uri, view(uri)); const v = views.get(uri); for (const r of v?.refs ?? []) if (r.hash === hash && r.from === d) out.push({ uri, range: span(v!.text, r.start, r.end) }); } return out; }; const namedDependents = (hash: string) => cb!.store.namedDependents(hash).length; /** The codebase definition at a position: a reference or declaration in a view, or a codebase name in a scratch file. */ const hashAt = (uri: string, p: Position): string | undefined => { if (isView(uri)) { const v = view(uri); if (!v) return undefined; const offset = lines_1(v.text).offset(p); const ref = v.refs.find((r) => offset >= r.start && offset <= r.end); if (ref) return ref.hash; return ( [...v.starts].filter(([, s]) => s <= offset).sort((a, b) => b[1] - a[1])[0]?.[0] ?? v.hashes[0] ); } const doc = docs.get(uri); if (!doc) return undefined; const s = fresh(doc); const offset = lines_1(doc.text).offset(p); const row = s.rows.find( (r) => offset >= r.def.range.start && offset <= r.def.range.end && r.status === "identical", ); return codebaseNameAt(doc, offset) ?? row?.def.hash; }; /** The codebase definition a name in a scratch file stands for (an import from #codebase, or a use of one). */ const codebaseNameAt = (doc: DocState, offset: number): string | undefined => { const a = fresh(doc).analysis; const id = identifierAt(doc, offset); if (!id || !a) return undefined; const checker = a.checker; let sym = checker.getSymbolAtLocation(id); if (sym && sym.flags & ts_SymbolFlags.Alias) sym = checker.getAliasedSymbol(sym); return (sym && cacheRefOf(checker)(sym)) || undefined; }; const SYMBOL_KIND = (rec: DefRecord) => rec.kind === "file" ? 1 : rec.kind === "class" ? 5 : rec.kind === "type" ? 11 : /^(<[^>]*>)?\(|^\{ ?[(<]/.test(rec.signature) ? 12 : 13; const viewSymbols = (uri: string) => { const v = view(uri); if (!v) return []; const order = [...v.starts].sort((a, b) => a[1] - b[1]); return order.map(([hash, start], i) => { const rec = cb!.store.getDef(hash)!; const end = order[i + 1]?.[1] ?? v.text.length; const self = v.refs.find((r) => r.hash === hash && r.from === hash); return { name: rec.nameHint, detail: cb!.store.shownSignature(rec), kind: SYMBOL_KIND(rec), range: span(v.text, start, end), selectionRange: self ? span(v.text, self.start, self.end) : span(v.text, start, start), }; }); }; const workspaceSymbols = (query: string) => { const q = query.toLowerCase(); // the letters of the query in order, as editors' symbol search does const fuzzy = (s: string) => { let i = 0; for (const ch of s.toLowerCase()) if (ch === q[i]) i++; return i === q.length; }; return cb!.store .names() .filter((n) => fuzzy(n.path)) .slice(0, 200) .map((n) => { const rec = cb!.store.getDef(n.hash)!; const dot = n.path.lastIndexOf("."); return { name: n.path.slice(dot + 1), containerName: dot > 0 ? n.path.slice(0, dot) : "", kind: SYMBOL_KIND(rec), location: { uri: viewUri(n.hash), range: span("", 0, 0) }, }; }); }; /** One level of the namespace tree: sub-namespaces, and the definitions directly in it. */ const tree = (ns: string) => { const c = cb!; const prefix = ns ? `${ns}.` : ""; const namespaces = new Map<string, number>(); const defs: { path: string; hash: string; kind: string }[] = []; // (files too: pages, configuration, images) for (const n of c.store.names(undefined, true)) { if (!n.path.startsWith(prefix)) continue; const rest = n.path.slice(prefix.length); const dot = rest.indexOf("."); if (dot >= 0) namespaces.set(rest.slice(0, dot), (namespaces.get(rest.slice(0, dot)) ?? 0) + 1); else defs.push(n); } const placement = placementNow(); return { namespaces: [...namespaces] .sort((a, b) => (a[0] < b[0] ? -1 : 1)) .map(([name, count]) => ({ name, path: prefix + name, count })), definitions: defs .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : a.kind < b.kind ? -1 : 1)) .map((n) => ({ ...entry(n.path, n.hash, placement), name: n.path.slice(prefix.length) })), }; }; /** One definition as the sidebar shows it: what it is, where it runs, what it's for. */ const entry = (p: string, hash: string, placement: Placement) => { const c = cb!; const rec = c.store.getDef(hash)!; const isTest = rec.kind !== "type" && isTestPath(p); const place = rec.kind === "term" ? placement.placeOf(hash).place : undefined; const test = isTest ? c.store.testResult(hash, keysForResults(c, [hash]).get(hash)!) : undefined; const role = roleOf(c, p, hash, placement); const endpoint = place === "server" && placement.crossable(hash); return { name: p, path: p, hash, kind: rec.kind, role, symbolKind: SYMBOL_KIND(rec), signature: c.store.shownSignature(rec), ...(isTest ? {} : { place }), ...(endpoint ? { endpoint: true, ...(placement.streams(hash) ? { streaming: true } : {}) } : {}), test: isTest ? (test ? (test.ok ? "passed" : "failed") : "not run") : undefined, doc: rec.doc?.split("\n")[0], uri: viewUri(hash), }; }; /** Rows per table, from the app data store (what `tcm data` lists). */ const rowCounts = (): Map<string, number> => { const file = path.join(cb!.lay.tcmDir, "data", "store.sqlite"); if (!fs.existsSync(file)) return new Map(); const db = new DatabaseSync(file, { readOnly: true }); try { return new Map( ( db.prepare("SELECT tbl, count(*) AS n FROM rows GROUP BY tbl").all() as { tbl: string; n: number; }[] ).map((r) => [r.tbl, r.n]), ); } finally { db.close(); } }; /** * The apps in the codebase, each with what it's made of: the plan `tcm build` makes (browser code, endpoints), * the tables its server code uses, and the jobs and tests in its namespace. */ const apps = () => { const c = cb!; const placement = placementNow(); const names = c.store.names(); const rows = rowCounts(); return names .filter((n) => n.kind === "term" && isApp(c, n.hash, c.store.getDef(n.hash)!, placement)) .map((app) => { const plan = placement.planApp(app.hash); const named = (hs: Iterable<string>) => [...hs] .flatMap((h) => c.store.pathsOf(h)[0] ? [entry(c.store.pathsOf(h)[0]!, h, placement)] : [], ) .sort((a, b) => (a.path < b.path ? -1 : 1)); // the tables the endpoints' code reaches const tables = new Map<string, string>(); const seen = new Set<string>(); const queue = [...plan.endpoints]; while (queue.length) { const h = queue.pop()!; if (seen.has(h)) continue; seen.add(h); const rec = c.store.getDef(h); if (!rec) continue; if (rec.table && c.store.pathsOf(h).length) tables.set(h, rec.table.name ?? c.store.pathsOf(h)[0]!); for (const d of rec.deps) if (d.ref.startsWith("#")) queue.push(d.ref.slice(1)); } const ns = app.path.includes(".") ? app.path.slice(0, app.path.lastIndexOf(".") + 1) : ""; const inNs = names.filter( (n) => n.path.startsWith(ns) && !n.path.slice(ns.length).includes("."), ); return { path: app.path, hash: app.hash, uri: viewUri(app.hash), endpoints: named(plan.endpoints), browser: named(plan.browser), tables: named(tables.keys()).map((t) => ({ ...t, table: tables.get(t.hash)!, rows: rows.get(tables.get(t.hash)!) ?? 0, })), schedules: inNs .filter( (n) => n.kind === "term" && isSchedule(c.store.getDef(n.hash)!, codebaseRoles(c)), ) .map((n) => ({ ...entry(n.path, n.hash, placement), spec: /every\(\s*(["'`])(.*?)\1/.exec(c.store.getDef(n.hash)!.sourceText)?.[2] ?? "", })), tests: inNs .filter((n) => n.kind !== "type" && isTestPath(n.path)) .map((n) => entry(n.path, n.hash, placement)), errors: plan.errors, }; }); }; /** * How to debug something: a Node launch configuration. Generated modules map to definition files and scratch * files, so breakpoints there hit; Node's own code and packages are skipped when stepping. */ const debugConfig = (kind: string, name: string | undefined, uri: string | undefined): Json => { const c = cb!; const base = { type: "node", request: "launch", cwd: c.lay.root, runtimeExecutable: process.execPath, skipFiles: ["<node_internals>/**", "**/node_modules/**"], resolveSourceMapLocations: ["**", "!**/node_modules/**"], sourceMaps: true, }; // a scratch file's own definitions (not saved yet) run as they are in its buffer const doc = uri ? docs.get(uri) : undefined; const scratch = doc && isScratch(doc.file) ? fresh(doc) : undefined; const src = scratch?.analysis ? c.source([...scratch.analysis.defs, ...scratch.analysis.watches]) : c.source(); const resolve = (n: string) => scratch?.rows.find((r) => r.path === n)?.def ?? c.store.getDef(c.lookupOne(n, "value")); if (kind === "run") { const rec = resolve(name!); if (!rec) throw new Error(`'${name}' not found`); return { ...base, name: `tcm run ${name}`, program: writeRunner(rec.hash, src, c.lay), runtimeArgs: nodeArgs(c.lay), console: "integratedTerminal", }; } if (kind === "test") { const tests = name ? [resolve(name)].filter((r): r is DefRecord => !!r) : (scratch?.rows ?? []) .filter((r) => r.def.kind !== "type" && isTestPath(r.path)) .map((r) => r.def); if (!tests.length) throw new Error(name ? `'${name}' not found` : "no tests here"); const { requestFile } = prepareTestRun(c, tests, src); return { ...base, name: `tcm test ${name ?? path.basename(doc!.file)}`, program: process.argv[1], args: ["internal-vitest", requestFile], autoAttachChildProcesses: true, }; } if (kind === "app") { // the server in Node; when tcm dev prints its URL, the page opens in a Chrome debug session return { ...base, name: `tcm dev ${name}`, program: process.argv[1], args: ["dev", name!, "--port", "0"], console: "integratedTerminal", serverReadyAction: { pattern: "url\\s+(https?://\\S+)", uriFormat: "%s", action: "debugWithChrome", webRoot: c.lay.root, killOnServerStop: true, }, }; } throw new Error(`can't debug a ${kind}`); }; /** Open the app data store read-only, or undefined while there's no data yet. */ const withStore = <T>(fn: (db: InstanceType<typeof DatabaseSync>) => T): T | undefined => { const file = path.join(cb!.lay.tcmDir, "data", "store.sqlite"); if (!fs.existsSync(file)) return undefined; const db = new DatabaseSync(file, { readOnly: true }); try { return fn(db); } finally { db.close(); } }; const summary = (v: unknown) => inspect(v, { depth: 2, breakLength: Infinity, maxArrayLength: 5, maxStringLength: 60 }); /** * Every table with data, and every table the codebase declares: rows, how many are stored as an older row type * than the declaring definition's, and that definition. */ const dataTables = () => { const declared = declaredTables(); const stored = withStore( (db) => db .prepare("SELECT tbl, schema_hash, count(*) AS n FROM rows GROUP BY tbl, schema_hash") .all() as { tbl: string; schema_hash: string; n: number }[], ) ?? []; const names = [...new Set([...declared.keys(), ...stored.map((r) => r.tbl)])].sort(); return names.map((name) => { const d = declared.get(name); const rows = stored.filter((r) => r.tbl === name); const total = rows.reduce((n, r) => n + r.n, 0); const older = d ? rows.filter((r) => r.schema_hash !== d.schema).reduce((n, r) => n + r.n, 0) : 0; return { table: name, rows: total, older, ...(d ? { path: d.path, hash: d.hash, uri: viewUri(d.hash), fields: d.fields } : {}), }; }); }; /** Tables the codebase declares: their definition, row schema, and the row type's fields (columns). */ const declaredTables = () => { const c = cb!; const out = new Map< string, { path: string; hash: string; schema: string; fields: { name: string; type: string }[] } >(); for (const n of c.store.names()) { const t = n.kind === "term" ? c.store.getDef(n.hash)?.table : undefined; if (!t?.name) continue; const row = t.s.t === "ref" ? t.defs[t.s.id] : t.s; const fields = row?.t === "object" ? Object.entries(row.props).map(([name, p]) => ({ name, type: schemaText(p.s, t.defs) + (p.optional ? "?" : ""), })) : []; out.set(t.name, { path: n.path, hash: n.hash, schema: t.hash, fields }); } return out; }; const FIELD = /^[A-Za-z_$][\w$]*$/; /** A cell: strings as they are, dates as ISO, small things inline, objects as one short line. */ const cell = (v: unknown): string => v === undefined || v === null ? "" : typeof v === "string" ? v : v instanceof Date ? v.toISOString() : typeof v === "object" ? summary(v) : String(v); /** * A page of a table's rows for the viewer: searched (anywhere in the row), sorted (by a field, the id or when it * was written; newest first by default) and paged in SQLite. */ const dataPage = ( table: string, q: { offset: number; limit: number; search?: string; sort?: { field: string; desc?: boolean } }, ) => { const d = declaredTables().get(table); return ( withStore((db) => { const where = `tbl = ?${q.search ? " AND instr(lower(data), lower(?)) > 0" : ""}`; const args = q.search ? [table, q.search] : [table]; const total = ( db.prepare(`SELECT count(*) AS n FROM rows WHERE ${where}`).get(...args) as { n: number } ).n; const f = q.sort?.field; const by = f === "id" ? "id" : f === "updated" || !f || !FIELD.test(f) ? "updated" : `json_extract(data, '$."${f}"')`; const dir = f ? (q.sort?.desc ? "DESC" : "ASC") : "DESC"; const rows = db .prepare( `SELECT id, data, schema_hash, updated FROM rows WHERE ${where} ORDER BY ${by} ${dir}, id LIMIT ? OFFSET ?`, ) .all(...args, q.limit, q.offset) as { id: string; data: string; schema_hash: string; updated: number; }[]; const values = rows.map((r) => decode(r.data) as Record<string, unknown>); // an undeclared table's columns are whatever its rows have const fields = d?.fields.length ? d.fields : [ ...new Set(values.flatMap((v) => (v && typeof v === "object" ? Object.keys(v) : []))), ].map((name) => ({ name, type: "" })); return { total, fields, rows: rows.map((r, i) => { const v = values[i]!; return { id: r.id, updated: r.updated, older: !!d && r.schema_hash !== d.schema, cells: Object.fromEntries( fields.map((fl) => [ fl.name, cell(v && typeof v === "object" ? v[fl.name] : undefined), ]), ), detail: inspect(v, { depth: null, breakLength: 80 }), summary: summary(v), uri: `tcm:/.data/${encodeURIComponent(table)}/${encodeURIComponent(r.id)}.txt`, }; }), }; }) ?? { total: 0, fields: d?.fields ?? [], rows: [] } ); }; /** One row, in full (`tcm:/.data/<table>/<id>.txt`). */ const rowText = (table: string, id: string) => withStore((db) => { const r = db .prepare("SELECT data, schema_hash, updated FROM rows WHERE tbl = ? AND id = ?") .get(table, id) as { data: string; schema_hash: string; updated: number } | undefined; if (!r) return `// table "${table}" has no row ${id} (any more)\n`; return `// table "${table}", row ${id}: written ${new Date(r.updated).toISOString()} as row type #${r.schema_hash.slice(0, 8)}\n\n${inspect(decode(r.data), { depth: null, breakLength: 100 })}\n`; }) ?? `// no data yet\n`; /** A table's newest rows, as text (`tcm:/.data/<table>.txt`). */ const tableText = (table: string) => { const file = path.join(cb!.lay.tcmDir, "data", "store.sqlite"); if (!fs.existsSync(file)) return `// table "${table}": no data yet\n`; const db = new DatabaseSync(file, { readOnly: true }); try { const total = ( db.prepare("SELECT count(*) AS n FROM rows WHERE tbl = ?").get(table) as { n: number } ).n; const rows = db .prepare( "SELECT id, data, schema_hash FROM rows WHERE tbl = ? ORDER BY updated DESC LIMIT 200", ) .all(table) as { id: string; data: string; schema_hash: string }[]; const lines = rows.map( (r) => `${r.id} ${inspect(decode(r.data), { depth: 4, breakLength: 120 })}`, ); return `// table "${table}": ${total} row${total === 1 ? "" : "s"}${total > rows.length ? `, newest ${rows.length} shown` : ""}\n\n${lines.join("\n")}\n`; } finally { db.close(); } }; /** A scratch file to edit in: scratch.ts if it's empty, else a fresh edit-N.ts (`.tsx` for JSX). */ // the scratch file what's edited goes to (added to what's in it): scratch.tsx for JSX, and for the rest too while // it holds work and scratch.ts doesn't const editFile = (ext = ".ts") => { const root = cb!.lay.root; const work = (f: string) => fs.existsSync(f) && !!fs.readFileSync(f, "utf8").trim(); const [plain, withJsx] = [path.join(root, "scratch.ts"), path.join(root, "scratch.tsx")]; return ext === ".tsx" || (work(withJsx) && !work(plain)) ? withJsx : plain; }; /** * Quick fixes in a scratch file: a codebase name it doesn't import (TypeScript can't find it), imported from the * namespace that has it; and a name it imports from the codebase, edited here too (added to this file). */ const codeActions = (doc: DocState, range: { start: Position; end: Position }) => { const c = cb!; const s = fresh(doc); const out: unknown[] = []; const imports = parse(doc.file, doc.text).statements.filter(ts_isImportDeclaration); const { pos, offset } = lines_1(doc.text); // a new import goes after the file's others, or its namespace line const last = imports.at(-1); const pragma = /^\/\/ tcm: namespace \S+\n/.exec(doc.text); const at = pos( last ? last.end + (doc.text[last.end] === "\n" ? 1 : 0) : pragma ? pragma[0].length : 0, ); for (const d of s.analysis?.diagnostics ?? []) { // "Cannot find name 'x'" (and "… Did you mean"), on these lines if (d.code !== 2304 && d.code !== 2552) continue; // (where it is: `file:line:col` in the formatted message, without its colours) const where = /:(\d+):(\d+)/.exec(stripVTControlCharacters(d.formatted)); const name = /'([^']+)'/.exec(d.message)?.[1]; const line = where ? Number(where[1]) - 1 : -1; if (!name || line < range.start.line || line > range.end.line) continue; const found = c.store .names() .filter((n) => n.path.slice(n.path.lastIndexOf(".") + 1) === name); for (const n of found.slice(0, 8)) { const ns = n.path.slice(0, Math.max(0, n.path.lastIndexOf("."))); const spec = ns ? `#codebase/${ns.replaceAll(".", "/")}` : "#codebase"; const newText = `import ${n.kind === "type" ? "type " : ""}{ ${name} } from "${spec}";\n`; out.push({ title: `Import ${name} from "${spec}"`, kind: "quickfix", isPreferred: found.length === 1, edit: { changes: { [doc.uri]: [{ range: { start: at, end: at }, newText }] } }, }); } } // the name at the cursor, if the file imports it from the codebase const o = offset(range.start); const word = /[\w$]*$/.exec(doc.text.slice(0, o))![0] + /^[\w$]*/.exec(doc.text.slice(o))![0]; for (const st of word ? imports : []) { const spec = ts_isStringLiteral(st.moduleSpecifier) ? st.moduleSpecifier.text : ""; const b = st.importClause?.namedBindings; if (!spec.startsWith("#codebase") || !b || !ts_isNamedImports(b)) continue; const e = b.elements.find((x) => x.name.text === word); if (!e) continue; const target = joinName( spec.slice("#codebase".length).replace(/^\//, "").replaceAll("/", "."), (e.propertyName ?? e.name).text, ); let hashes: string[] = []; try { hashes = c.lookup(target); } catch { continue; } // (a file is edited as the file it is; JSX goes only in a .tsx file) const recs = hashes.map((h) => c.store.getDef(h)); if ( !recs.length || recs.some((r) => !r || r.kind === "file" || (r.jsx && !doc.file.endsWith(".tsx"))) ) continue; const title = `Edit ${target} here too`; out.push({ title, kind: "refactor", command: { title, command: "tcm.editThisToo", arguments: [doc.uri, target] }, }); } return out; }; /** Lenses on a definition's view: how many definitions use it (click for where), and edit / rename. */ const viewLenses = (uri: string) => { const v = view(uri); if (!v) return []; const { pos } = lines_1(v.text); return [...v.starts].flatMap(([hash, start]) => { const p = pos(start); const range = { start: p, end: p }; const users = namedDependents(hash); const name = cb!.store.pathsOf(hash)[0]; const out: Json[] = [ { range, command: { title: `${users} dependent${users === 1 ? "" : "s"}`, command: isVsCode ? "tcm.findUsages" : "", arguments: [uri, p.line, p.character], }, }, ]; if (isVsCode && name) { out.push({ range, command: { title: "Edit", command: "tcm.editDefinition", arguments: [name] }, }); out.push({ range, command: { title: "Rename", command: "tcm.rename", arguments: [name] } }); const rec = cb!.store.getDef(hash); if ( rec?.kind === "term" && (isTestPath(name) || /^(<[^>]*>)?\(\)\s*=>/.test(rec.signature)) ) { out.push({ range, command: { title: "Debug", command: isTestPath(name) ? "tcm.debugTest" : "tcm.debugDefinition", arguments: [name], }, }); } } return out; }); }; const uriOf = (params: Json | undefined) => String((params?.textDocument as Json | undefined)?.uri ?? ""); const docAt = (params: Json | undefined) => { const uri = String((params?.textDocument as Json | undefined)?.uri ?? ""); const doc = docs.get(uri); return doc && cb ? doc : undefined; }; const handle = (m: Message) => { const p = m.params; switch (m.method) { case "initialize": { const folders = (p?.workspaceFolders as { uri: string }[] | null | undefined) ?? []; const rootUri = (p?.rootUri as string | null | undefined) ?? folders[0]?.uri; const dir = rootUri ? fileURLToPath(rootUri) : process.cwd(); try { cb = findCodebase(dir); } catch (e) { process.stderr.write(`tcm lsp: ${(e as Error).message}\n`); } isVsCode = /visual studio code/i.test( String((p?.clientInfo as Json | undefined)?.name ?? ""), ); const ws = ((p?.capabilities as Json | undefined)?.workspace ?? {}) as Json; canRefresh = { lens: !!(ws.codeLens as Json | undefined)?.refreshSupport, hints: !!(ws.inlayHint as Json | undefined)?.refreshSupport, }; send({ id: m.id, result: { capabilities: { // willSaveWaitUntil: scratch files are formatted (oxfmt) as they're saved, with no editor setup textDocumentSync: { openClose: true, change: 1, save: { includeText: false }, willSaveWaitUntil: true, }, documentFormattingProvider: true, hoverProvider: true, codeLensProvider: { resolveProvider: false }, codeActionProvider: { codeActionKinds: ["quickfix", "refactor"] }, inlayHintProvider: true, executeCommandProvider: { commands: [ "tcm.add", "tcm.update", "tcm.mv", "tcm.edit", "tcm.editInto", "tcm.dataMigrate", "tcm.upgrade", "tcm.release", "tcm.releaseDraft", "tcm.pack", ], }, definitionProvider: true, referencesProvider: true, documentSymbolProvider: true, workspaceSymbolProvider: true, }, serverInfo: { name: "tcm", version: "1" }, }, }); return; } case "initialized": if (!cb) message(2, "tcm: no codebase in this workspace (run `tcm init`)"); else watchCodebase(); return; case "shutdown": send({ id: m.id, result: null }); return; case "exit": process.exit(0); case "textDocument/didOpen": { const td = p!.textDocument as { uri: string; text: string; version: number }; const file = fileURLToPath(td.uri); if (!isScratch(file)) return; const doc: DocState = { uri: td.uri, file, text: td.text, version: td.version }; docs.set(td.uri, doc); schedule(doc, true); return; } case "textDocument/didChange": { const doc = docAt(p); if (!doc) return; const changes = p!.contentChanges as { text: string }[]; doc.text = changes.at(-1)!.text; doc.version = (p!.textDocument as { version: number }).version; schedule(doc, false); return; } case "textDocument/didSave": { const doc = docAt(p); if (doc) schedule(doc, true); return; } case "textDocument/didClose": { const doc = docAt(p); if (!doc) return; docs.delete(doc.uri); notify("textDocument/publishDiagnostics", { uri: doc.uri, diagnostics: [] }); return; } case "textDocument/codeAction": { const doc = docAt(p); send({ id: m.id, result: cb && doc && p?.range ? codeActions(doc, p.range as { start: Position; end: Position }) : [], }); return; } case "textDocument/codeLens": { const uri = uriOf(p); if (cb && isView(uri)) return send({ id: m.id, result: viewLenses(uri) }); const doc = docAt(p); send({ id: m.id, result: doc ? codeLenses(doc) : [] }); return; } case "textDocument/definition": { // a codebase name goes to its source (a tcm: document): in a view, references are links; in a scratch file, // names from #codebase (TypeScript would answer with the generated module; the VS Code extension's // TypeScript plugin drops that answer). Everything else is TypeScript's. const uri = uriOf(p); let target: string | undefined; if (cb && isView(uri)) { const v = view(uri); const offset = v ? lines_1(v.text).offset(p!.position as Position) : -1; target = v?.refs.find((r) => offset >= r.start && offset <= r.end)?.hash; } else { const doc = docAt(p); if (doc) target = codebaseNameAt(doc, lines_1(doc.text).offset(p!.position as Position)); } send({ id: m.id, result: target ? (declaration(target) ?? null) : null }); return; } case "textDocument/references": { const uri = uriOf(p); const hash = cb ? hashAt(uri, p!.position as Position) : undefined; const decl = hash && (p!.context as Json | undefined)?.includeDeclaration ? declaration(hash) : undefined; send({ id: m.id, result: hash ? [...(decl ? [decl] : []), ...usages(hash)] : [] }); return; } case "textDocument/documentSymbol": { const uri = uriOf(p); send({ id: m.id, result: cb && isView(uri) ? viewSymbols(uri) : [] }); return; } case "workspace/symbol": send({ id: m.id, result: cb ? workspaceSymbols(String(p?.query ?? "")) : [] }); return; // for clients that show the codebase itself (the VS Code extension's sidebar and views) case "tcm/tree": send({ id: m.id, result: cb ? tree(String(p?.namespace ?? "")) : { namespaces: [], definitions: [] }, }); return; case "tcm/data": send({ id: m.id, result: cb ? dataTables() : [] }); return; case "tcm/rows": send({ id: m.id, result: cb ? dataPage(String(p?.table), { offset: Math.max(0, Number(p?.offset ?? 0)), limit: Math.min(500, Math.max(1, Number(p?.limit ?? 100))), search: typeof p?.search === "string" && p.search ? p.search : undefined, sort: p?.sort as { field: string; desc?: boolean } | undefined, }) : { total: 0, fields: [], rows: [] }, }); return; // pages, as in Unison's codebase UI case "tcm/page": { const target = p?.hash ? String(p.hash) : p?.uri ? hashesOf(String(p.uri))[0] : p?.path ? cb?.lookup(String(p.path))[0] : undefined; const placement = cb && placementNow(); const page = cb && target ? definitionPage(cb, target, placement!, { viewUri }) : undefined; // (an app's page starts it, rather than running it as a program) const rec = target ? cb?.store.getDef(target) : undefined; send( page ? { id: m.id, result: { ...page, app: !!rec && isApp(cb!, target!, rec, placement!) } } : { id: m.id, error: { code: -32602, message: "no such definition" } }, ); return; } case "tcm/namespace": { if (!cb) return send({ id: m.id, result: null }); const ns = String(p?.namespace ?? ""); const t = tree(ns); // a namespace's front page is its README definition's doc, as in Unison const readme = t.definitions.find((d) => d.name === "README"); const rec = readme && cb.store.getDef(readme.hash); send({ id: m.id, result: { namespace: ns, readme: rec ? renderDoc(cb, rec, { evaluate: true, link: (h) => `tcm-page:${h}` })?.markdown : undefined, namespaces: t.namespaces, definitions: t.definitions .filter((d) => d !== readme) .map((d) => ({ ...d, summary: docSummary(cb!.store.getDef(d.hash)!) })), }, }); return; } case "tcm/find": send({ id: m.id, result: cb ? find_1(cb, String(p?.query ?? ""), viewUri) : [] }); return; // the Changes view: branches, this branch's history, and what an entry changed case "tcm/branches": send({ id: m.id, result: cb ? branchList(cb) : [] }); return; case "tcm/history": send({ id: m.id, result: cb ? historyList(cb, Number(p?.limit ?? 30)) : [] }); return; case "tcm/branchChanges": try { send({ id: m.id, result: cb ? branchChanges(cb, String(p?.branch ?? ""), lazyUri) : [] }); } catch (e) { send({ id: m.id, error: { code: -32602, message: (e as Error).message } }); } return; case "tcm/historyEntry": send({ id: m.id, result: cb ? entryChanges(cb, String(p?.hash ?? ""), lazyUri) : [] }); return; // find in files, for the codebase: lines of definitions and pages with the text in them case "tcm/searchCode": send({ id: m.id, result: cb ? searchCode( cb, String(p?.query ?? ""), { caseSensitive: !!p?.caseSensitive, wholeWord: !!p?.wholeWord, regex: !!p?.regex, ...(p?.under ? { under: String(p.under) } : {}), }, viewUri, ) : { hits: [], matches: 0, truncated: false }, }); return; case "tcm/apps": send({ id: m.id, result: cb ? apps() : [] }); return; // a launch configuration for VS Code's JavaScript debugger: run a definition, a test, or an app (server in // Node, page in Chrome), with breakpoints in definition files and scratch files case "tcm/debug": { try { send({ id: m.id, result: debugConfig( String(p?.kind), p?.name === undefined ? undefined : String(p.name), p?.uri === undefined ? undefined : String(p.uri), ), }); } catch (e) { send({ id: m.id, error: { code: -32602, message: (e as Error).message } }); } return; } // the codebase's own packages (manifests) and their releases case "tcm/publishing": case "tcm/releaseCheck": case "tcm/releasePage": case "tcm/releaseList": case "tcm/changelog": try { const name = String(p?.name ?? ""); const result = !cb ? [] : m.method === "tcm/publishing" ? publishingList(cb) : m.method === "tcm/releaseCheck" ? releaseCheck(cb, name) : m.method === "tcm/releasePage" ? releasePage(cb, name) : m.method === "tcm/releaseList" ? releaseList(cb, name) : { markdown: changelog(cb, name) }; send({ id: m.id, result }); } catch (e) { send({ id: m.id, error: { code: -32602, message: (e as Error).message } }); } return; // the packages the codebase runs: the lock, what uses each, and a page per package and range case "tcm/packages": send({ id: m.id, result: cb ? packageList(cb) : [] }); return; case "tcm/packageUsers": send({ id: m.id, result: cb ? packageUsers(cb, String(p?.name), String(p?.range)) : [] }); return; case "tcm/packagePage": try { const page = packagePage(cb!, String(p?.name), String(p?.range)); send({ id: m.id, result: { ...page, users: page.users.map((u) => ({ ...u, uri: viewUri(u.hash) })) }, }); } catch (e) { send({ id: m.id, error: { code: -32602, message: (e as Error).message } }); } return; case "tcm/source": { const row = /^tcm:\/*\.data\/([^/]+)\/([^/]+)\.txt$/.exec(String(p?.uri)); if (cb && row) return send({ id: m.id, result: { text: rowText(decodeURIComponent(row[1]!), decodeURIComponent(row[2]!)) }, }); const data = /^tcm:\/*\.data\/([^/]+)\.txt$/.exec(String(p?.uri)); if (cb && data) return send({ id: m.id, result: { text: tableText(decodeURIComponent(data[1]!)) } }); const v = cb ? view(String(p?.uri)) : undefined; send( v ? { id: m.id, result: { text: v.text } } : { id: m.id, error: { code: -32602, message: `${p?.uri} is not in the codebase` } }, ); return; } case "tcm/findByType": { const r = searchByType(cb!, String(p?.query ?? "")); send({ id: m.id, result: { variables: r.variables, hits: r.hits.map((h) => ({ ...h, uri: viewUri(h.hash) })), }, }); return; } case "textDocument/inlayHint": { const doc = docAt(p); send({ id: m.id, result: doc ? inlayHints(doc) : [] }); return; } // oxfmt: Format Document, and formatting as a scratch file is saved case "textDocument/formatting": case "textDocument/willSaveWaitUntil": { const doc = docs.get(uriOf(p)); let edits: Json[] = []; if (cb && doc && isScratch(doc.file)) { try { const formatted = formatText(cb, doc.text, doc.file); if (formatted !== undefined && formatted !== doc.text) { const { pos } = lines_1(doc.text); edits = [ { range: { start: { line: 0, character: 0 }, end: pos(doc.text.length) }, newText: formatted, }, ]; } } catch (e) { message(2, `formatting: ${(e as Error).message}`); } } send({ id: m.id, result: edits }); return; } case "textDocument/hover": { const uri = uriOf(p); if (cb && isView(uri)) { const hash = hashAt(uri, p!.position as Position); const value = hash && describe(hash); return send({ id: m.id, result: value ? { contents: { kind: "markdown", value } } : null, }); } const doc = docAt(p); send({ id: m.id, result: doc ? hover(doc, p!.position as Position) : null }); return; } case "workspace/executeCommand": // each command is its own history entry (this process outlives many) cb?.store.endBatch(); // a command may finish later (migrating data runs the table's code) Promise.resolve( cb ? execute(String(p!.command), (p!.arguments as unknown[]) ?? []) : null, ).then( (result) => send({ id: m.id, result }), (e) => send({ id: m.id, error: { code: -32603, message: (e as Error)?.message ?? String(e) }, }), ); return; default: // requests we don't serve get an error; notifications are ignored, and responses to our requests too if (m.method && m.id !== undefined) send({ id: m.id, error: { code: -32601, message: `${m.method} not supported` } }); } }; }
uses
- tcmts.cli.changes.NamesFrom
- tcmts.cli.changes.branchChanges
- tcmts.cli.changes.branchList
- tcmts.cli.changes.entryChanges
- tcmts.cli.changes.historyList
- tcmts.cli.changes.namesFrom
- tcmts.cli.codebase.Codebase
- tcmts.cli.codec.decode
- tcmts.cli.contributions.author
- tcmts.cli.docs.renderDoc
- tcmts.cli.effects.reaches
- tcmts.cli.fmt.formatText
- tcmts.cli.lint.lintScratch
- tcmts.cli.lsp.DocState
- tcmts.cli.lsp.Json
- tcmts.cli.lsp.LspTools
- tcmts.cli.lsp.Message
- tcmts.cli.lsp.Position
- tcmts.cli.lsp.Row
- tcmts.cli.lsp.connect
- tcmts.cli.lsp.lines
- tcmts.cli.lsp.plain
- tcmts.cli.mirror.joinName
- tcmts.cli.package_pages.packageList
- tcmts.cli.package_pages.packagePage
- tcmts.cli.package_pages.packageUsers
- tcmts.cli.pages.definitionPage
- tcmts.cli.pages.docSummary
- tcmts.cli.pages.find
- tcmts.cli.pages.isApp
- tcmts.cli.pages.isSchedule
- tcmts.cli.pages.roleOf
- tcmts.cli.pages.searchCode
- tcmts.cli.placement.Placement
- tcmts.cli.release_pages.publishingList
- tcmts.cli.release_pages.releaseCheck
- tcmts.cli.release_pages.releaseList
- tcmts.cli.release_pages.releasePage
- tcmts.cli.releases.changelog
- tcmts.cli.releases.contributionsOn
- tcmts.cli.releases.generatedNotes
- tcmts.cli.releases.packPackage
- tcmts.cli.releases.planRelease
- tcmts.cli.releases.reasonFor
- tcmts.cli.releases.releasePackage
- tcmts.cli.releases.writeDraft
- tcmts.cli.roles.codebaseRoles
- tcmts.cli.search.searchByType
- tcmts.cli.testing.evaluateCached
- tcmts.cli.testing.isTestPath
- tcmts.cli.testing.runCached
- tcmts.cli.upgrade.upgradeTo
- tcmts.cli.upgrade.upgradeWithin
- tcmts.cli.views.View
- tcmts.cli.views.ensureView
- tcmts.cli.views.hashesOfViewFile
- tcmts.cli.views.srcDir
- tcmts.cli.views.viewText
- tcmts.cli.vitest.keysForResults
- tcmts.cli.vitest.prepareTestRun
- tcmts.core.analyze.Analysis
- tcmts.core.hash.shortHash
- tcmts.core.schema.schemaText
- tcmts.core.shape.cacheRefOf
- tcmts.core.ts.forEachChild
- tcmts.core.ts.parse
- tcmts.core.types.DefRecord
- tcmts.loader.nodeArgs
- tcmts.loader.writeRunner