Files
codegraph/src/ui-server/api/filecode.ts
T
Colby McHenry ad91c8fdd8 feat(ui): classify code from the engine's own tree-sitter parse, retiring Shiki (CG-57)
The viewer ran a second highlighter over source the engine had already parsed
with a real grammar: Shiki, plus 56 pruned TextMate grammars shipped in
dist/textmate/. The classification now comes off that tree instead, so a file is
read by exactly the grammar that decided what its symbols are.

The swap is complete rather than flagged: @shikijs/core, @shikijs/engine-javascript
and @shikijs/langs are off the dependency list, scripts/prune-grammars.mjs and
`npm run build:textmate` are deleted, and check-ui-build.mjs asserts the
tree-sitter grammars in dist/extraction/wasm instead of dist/textmate.

The wire contract is unchanged — `[classId, text]` pairs with the class names
alongside — so the viewer's decoder and code blocks did not have to be rewritten.
Two classes are added to the six: `type` (a named type reference, painted at
plain ink) and `def` (the name a definition declares, weight 600), the latter
taken from the extractors' own definition tables so it cannot drift from what
indexing calls a definition.

Three differences are not cosmetic:

* Interpolations (`${…}`, `#{…}`, `$"{…}"`, f-strings) are classified as code,
  not as string. The call-site overlay refuses to claim a token classed string,
  so calls written inside interpolated strings now link.
* Built-in type words are emitted whole and classed `type` in every language.
  The grammars disagree about whether `string` is a type_identifier or an
  anonymous token inside a predefined_type, and TextMate scoped them
  inconsistently too.
* 3 000 lines of TypeScript cost 24-41 ms instead of ~700 ms.

Given up deliberately: Liquid, Razor, YAML, Twig, XML and .properties render
plain. .svelte/.vue/.astro are classified through their <script> blocks, the same
delegation the SFC extractors do. Pulling html/css/vue out of tree-sitter-wasms
would cover them, but those ABI-13 builds are the known cause of shared-WASM-heap
corruption for every other language in the same process.

Measured parity, per-language before/after screenshots and the reproduction
recipe: docs/design/cg57-highlighting-parity.md.
2026-08-27 06:22:11 -05:00

298 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `GET /api/filecode/<path>` — the whole-file source view in one round-trip.
*
* The Symbol view asks "what does this body reach"; this screen asks the same
* question of every line of a file at once, and answers it beside the file's
* own source. What that needs is one payload holding everything the graph says
* about lines in this file, and nothing that depends on scroll position:
*
* * the file's symbols in source order — the sticky outline rail, and the
* definition line every intra-file arc lands on,
* * one row per (calling symbol, called symbol) pair, carrying the call-site
* lines the gutter ports and the callee rail anchor to,
* * the references that never resolved, so a line that reaches `console.log`
* still shows a hollow port instead of an empty gutter that reads as
* "nothing happens here",
* * the file's total line count, which IS the layout: every line is a fixed
* height, so the viewer can size a 6 800-line document and start drawing
* before a single page of source has arrived.
*
* The source itself does NOT ride along. A 6 800-line TypeScript file is ~1.5 s
* of parsing and megabytes of JSON; the viewer pages it through
* `/api/source` as the reader scrolls, which is also what lets the graph
* facts — ports, arcs, rail rows — be complete from the first frame while the
* text fills in behind them.
*
* **The arcs are not a separate list.** An arc is a call whose target is
* defined in this same file, so the viewer derives them from `calls` and the
* `intraFileCalls` count here is computed from the SHOWN groups for the same
* reason: a header that counted raw edges would disagree with the picture under
* it the moment a cap bit.
*/
import type { CodeGraph } from '../../index';
import type { Edge, Node } from '../../types';
import { isTestFile } from '../../search/query-utils';
import { buildOutlineEntries, type WireOutlineEntry } from './file';
import { readFileShape, resolveRequestedFile } from './source';
import { badRequest } from './respond';
import {
firstLine,
groupRelations,
toPosixPath,
wireList,
type WireList,
type WireRelation,
} from './wire';
/**
* Call groups returned for one file.
*
* A generous cap, not a display budget: the viewer only ever draws the rows in
* the window it is scrolled to, so the number that matters is what a payload
* costs to ship. This repo's largest file (`src/mcp/tools.ts`, 6 820 lines)
* produces 498.
*/
export const MAX_FILE_CALL_GROUPS = 2000;
/**
* Unresolved references returned for one file.
*
* These are markers, not rows — each one is a hollow port and a soft underline
* with nothing behind it. `src/mcp/tools.ts` has 1 113; a generated bundle can
* have tens of thousands, and past this point the count says everything the
* list would.
*/
export const MAX_FILE_OUTSIDE_REFS = 3000;
/**
* Unresolved-reference rows read before the count itself becomes a floor.
*
* `total` has to be the real number — the rest of this API guarantees that a
* count equals a list — and the filter below (plain identifiers only) is not
* expressible in SQL, so the rows have to be scanned to be counted. This is the
* backstop against a generated bundle with a million of them, and it is far
* above anything hand-written: the largest file in this repo's own index has
* 1 113.
*/
export const MAX_FILE_OUTSIDE_SCAN = 50_000;
/** A reference the resolver never landed: a port with no destination. */
export interface WireFileOutsideRef {
line: number;
col: number;
/** The identifier as written — how the viewer finds the token to underline. */
name: string;
kind: string;
}
/** Every edge from ONE symbol in this file to ONE symbol anywhere. */
export interface WireFileCall {
/**
* The symbol in this file that makes the calls.
*
* Never null: extraction records a statement outside every definition as an
* edge out of the FILE node, so top-level code has an owner too — the file
* itself.
*/
ownerId: string;
/** First line of the owner's definition, so a rail row can be attributed. */
ownerLine: number;
relation: WireRelation;
}
export interface WireFileCodePayload {
file: {
path: string;
language: string;
size: number;
indexedAt: number;
contentHash: string;
generated: boolean;
test: boolean;
errors: string[];
/** The file node's own id — the owner of every top-level call. */
id: string | null;
/**
* Lines on disk right now. Null when the file could not be read, which is
* the one case the viewer cannot lay out and says so.
*/
totalLines: number | null;
};
/** The file changed on disk since it was indexed — every line number is suspect. */
drift: boolean;
/** Why, when there is something to say beyond the flag. */
reason?: string;
/** The file's symbols in source order — the same rows `/api/file` draws. */
outline: WireList<WireOutlineEntry>;
/** One row per (calling symbol, called symbol) pair, in call-site order. */
calls: WireList<WireFileCall>;
/** References with nothing behind them — hollow ports. */
outside: WireList<WireFileOutsideRef>;
/**
* Calls landing on a definition in THIS file — the arc diagram's total.
*
* Counted over the groups actually returned, so it always equals the number
* of arcs the viewer can draw from this payload.
*/
intraFileCalls: number;
timing: { elapsedMs: number };
}
export function buildFileCode(
cg: CodeGraph,
projectRoot: string,
requested: string
): WireFileCodePayload {
const started = Date.now();
if (requested === '') throw badRequest('No file path was given. Use /api/filecode/<path>.');
// Refusal first, index lookup second — see `resolveRequestedFile`.
const { record, storedPath } = resolveRequestedFile(cg, projectRoot, requested);
const posixPath = toPosixPath(storedPath);
const nodes = cg.getNodesInFile(storedPath);
const fileNode = nodes.find((n) => n.kind === 'file') ?? null;
const { entries: outline, total: outlineTotal } = buildOutlineEntries(cg, nodes);
const { calls, total: callTotal, intraFileCalls } = buildCalls(cg, nodes, posixPath);
const outside = buildOutsideRefs(cg, storedPath);
// One read answers both the drift verdict and the document's height.
const shape = readFileShape(projectRoot, storedPath, record);
return {
file: {
path: posixPath,
language: record.language,
size: record.size,
indexedAt: record.indexedAt,
contentHash: record.contentHash,
generated: record.generated === true,
test: isTestFile(posixPath),
// Messages, not the raw records: the screen prints a count and a line,
// and an extractor's file/line bookkeeping is not something a reader acts
// on.
errors: (record.errors ?? []).map((e) => e.message),
id: fileNode?.id ?? null,
totalLines: shape.totalLines,
},
drift: shape.drift,
...(shape.reason ? { reason: shape.reason } : {}),
outline: wireList(outline, outlineTotal),
calls: wireList(calls, callTotal),
outside: wireList(outside.items, outside.total),
intraFileCalls,
timing: { elapsedMs: Date.now() - started },
};
}
/**
* Every outgoing edge from every symbol in the file, grouped twice over: by the
* symbol that makes the call, and within that by the symbol it reaches.
*
* Grouping by the OWNER as well as the target is what separates this from the
* Symbol view's rail. Across one body, a helper called from three lines is one
* row with `×3` and one place to sit. Across a 6 800-line file, the same helper
* called from two different functions a thousand lines apart cannot be one row
* — a row is anchored to a line, and there is no line that is both. So the pair
* is the unit, and the rail reads in source order the way the file does.
*
* `contains` is excluded, as everywhere else: it is structure, not dependency,
* and the outline already draws it.
*/
function buildCalls(
cg: CodeGraph,
nodes: readonly Node[],
posixPath: string
): { calls: WireFileCall[]; total: number; intraFileCalls: number } {
const nodeIds = nodes.map((n) => n.id);
const lineOf = new Map(nodes.map((n) => [n.id, n.startLine] as const));
const edges = cg.getOutgoingEdgesFrom(nodeIds).filter((e) => e.kind !== 'contains');
if (edges.length === 0) return { calls: [], total: 0, intraFileCalls: 0 };
const bySource = new Map<string, Edge[]>();
for (const edge of edges) {
const bucket = bySource.get(edge.source);
if (bucket) bucket.push(edge);
else bySource.set(edge.source, [edge]);
}
// One batched lookup for every counterpart, never one per edge: the engine's
// busiest file reaches several hundred distinct symbols.
const endpoints = cg.getNodesByIds([...new Set(edges.map((e) => e.target))]);
const all: WireFileCall[] = [];
for (const [ownerId, group] of bySource) {
for (const relation of groupRelations(group, (e) => e.target, endpoints)) {
all.push({ ownerId, ownerLine: lineOf.get(ownerId) ?? 0, relation });
}
}
// Source order — the only ordering this screen has. A row with no recorded
// call site (an edge the extractor gave no line) sorts to the end, where it
// is also what a cap trims first.
all.sort(
(a, b) =>
firstLine(a.relation) - firstLine(b.relation) ||
a.ownerLine - b.ownerLine ||
a.relation.node.name.localeCompare(b.relation.node.name)
);
const calls = all.slice(0, MAX_FILE_CALL_GROUPS);
// Arcs, counted over what was KEPT — see the module comment.
let intraFileCalls = 0;
for (const call of calls) {
if (call.relation.node.file !== posixPath) continue;
const target = call.relation.node.line;
for (const line of call.relation.lines) if (line !== target) intraFileCalls++;
}
return { calls, total: all.length, intraFileCalls };
}
/**
* The file's unresolved references, as line markers.
*
* Only plain identifiers survive. The resolver's samples are bookkeeping, and a
* "name" that is really a whole arrow function or a receiver expression cannot
* be matched to a token on the line — a marker that could not find its
* identifier would silently claim the wrong one, which is worse than no marker.
* The same filter the Symbol view applies, applied once here rather than per
* symbol.
*/
function buildOutsideRefs(
cg: CodeGraph,
storedPath: string
): { items: WireFileOutsideRef[]; total: number } {
let raw;
try {
// Scanned, not capped at the display limit: `total` must be the real count
// and the identifier filter below cannot run in SQL.
raw = cg.getUnresolvedReferencesInFile(storedPath, MAX_FILE_OUTSIDE_SCAN);
} catch {
return { items: [], total: 0 };
}
const items: WireFileOutsideRef[] = [];
let total = 0;
for (const ref of raw) {
const name = lastSegment(ref.referenceName ?? '');
if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue;
if (!ref.line) continue;
total++;
if (items.length < MAX_FILE_OUTSIDE_REFS) {
items.push({ line: ref.line, col: ref.column ?? 0, name, kind: ref.referenceKind });
}
}
return { items, total };
}
/** The trailing segment of a dotted name — what actually appears in the source. */
function lastSegment(name: string): string {
const dot = name.lastIndexOf('.');
return dot < 0 ? name : name.slice(dot + 1);
}