| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302 |
- /**
- * `GET /api/file/<path>` — the File view in one round-trip.
- *
- * Three panes: what imports this file, the file's own outline in source order,
- * and what this file imports. All of it comes from four batched queries — the
- * file's nodes, their `contains` edges, their `imports` edges in each
- * direction — never a query per symbol.
- *
- * Two things worth knowing about `imports` edges before reading the mapping
- * below. First, they point at the *symbol* that was imported, not at the file
- * holding it, so file granularity means mapping each edge's endpoint through
- * `nodes.file_path`. Second, plenty of them stay inside one file (an import
- * declaration is a node in the importing file), so the same-file ones have to
- * be dropped or every file appears to import itself.
- *
- * The rails would still read as broken without the third piece: imports that
- * never resolved. A file importing `react`, `fs` and one local module would
- * otherwise show a single row, silently implying the other two do not exist.
- * They are listed separately, as what they are — outside the index.
- */
- import type { CodeGraph } from '../../index';
- import type { Edge, Node } from '../../types';
- import { isTestFile } from '../../search/query-utils';
- import { hasDriftedOnDisk, resolveRequestedFile } from './source';
- import {
- MAX_IMPORT_FILES,
- MAX_OUTLINE_NODES,
- toNodeRef,
- toPosixPath,
- wireList,
- type WireNodeRef,
- } from './wire';
- /** Symbols named per import row before it just counts them. */
- const MAX_SYMBOLS_PER_IMPORT = 12;
- /** Unresolved imports listed by name. */
- const MAX_UNRESOLVED_IMPORTS = 60;
- /** A row in the file outline. */
- export interface WireOutlineEntry extends WireNodeRef {
- /** Containing symbol within this file, or null for a top-level one. */
- parentId: string | null;
- /** Nesting depth from the top level of the file, starting at 0. */
- depth: number;
- /** Incoming / outgoing edge counts — the `← in → out` column. */
- fanIn: number;
- fanOut: number;
- }
- /** One end of the File view's import rails. */
- export interface WireImportRow {
- file: string;
- test: boolean;
- /** Which symbols the edges name, capped. */
- symbols: Array<{ id: string; name: string; kind: string; line: number }>;
- symbolCount: number;
- }
- export function buildFile(cg: CodeGraph, projectRoot: string, requested: string): unknown {
- // Refusal first, index lookup second — a traversal out of the project is a
- // refusal, not "no such file". See `resolveRequestedFile`.
- const { record, storedPath } = resolveRequestedFile(cg, projectRoot, requested);
- const nodes = cg.getNodesInFile(storedPath);
- const nodeIds = nodes.map((n) => n.id);
- const inThisFile = new Set(nodeIds);
- const nodeKindById = new Map(nodes.map((n) => [n.id, n.kind]));
- const fileNode = nodes.find((n) => n.kind === 'file') ?? null;
- // ---------------------------------------------------------------------------
- // Outline
- // ---------------------------------------------------------------------------
- const { entries: outline, total: outlineTotal } = buildOutlineEntries(cg, nodes);
- // ---------------------------------------------------------------------------
- // Import rails
- // ---------------------------------------------------------------------------
- const importsOut = cg.getOutgoingEdgesFrom(nodeIds, ['imports']);
- const importsIn = cg.getIncomingEdgesTo(nodeIds, ['imports']);
- const endpointIds = new Set<string>();
- for (const edge of importsOut) if (!inThisFile.has(edge.target)) endpointIds.add(edge.target);
- for (const edge of importsIn) if (!inThisFile.has(edge.source)) endpointIds.add(edge.source);
- const endpoints = cg.getNodesByIds([...endpointIds]);
- const imports = groupByFile(
- importsOut.filter((e) => !inThisFile.has(e.target)),
- (e) => e.target,
- endpoints
- );
- const importedBy = groupByFile(
- importsIn.filter((e) => !inThisFile.has(e.source)),
- (e) => e.source,
- endpoints
- );
- // Import statements that never resolved — the third-party packages and
- // runtime builtins. Attributed to the file node, which is where extraction
- // records a file-level import.
- const unresolvedImports = fileNode ? unresolvedImportsOf(cg, fileNode.id) : [];
- // Whether the file RUNS anything at its top level. Extraction records a
- // statement outside any definition as an edge out of the FILE node, so a
- // module that only defines things has none and a CLI entry point has many —
- // the same signal `/api/entrypoints` ranks on. It is worth a line on this
- // screen because the outline cannot show it: top-level code belongs to no
- // symbol, so the only way to read it is to open the file node itself.
- // A call made while initializing a module-level variable or constant is
- // attributed to that name (#693), so those names are top-level code too and
- // are counted with the file — the same set `getTopCallingFiles` ranks on.
- const moduleLevelValueIds = fileNode
- ? cg
- .getOutgoingEdgesFrom([fileNode.id], ['contains'])
- .map((e) => e.target)
- .filter((id) => {
- const kind = nodeKindById.get(id);
- return kind === 'variable' || kind === 'constant';
- })
- : [];
- const topLevelEdges = fileNode
- ? cg.getOutgoingEdgesFrom([fileNode.id, ...moduleLevelValueIds], ['calls', 'instantiates'])
- : [];
- return {
- file: {
- path: toPosixPath(storedPath),
- language: record.language,
- size: record.size,
- modifiedAt: record.modifiedAt,
- indexedAt: record.indexedAt,
- contentHash: record.contentHash,
- nodeCount: record.nodeCount,
- generated: record.generated === true,
- test: isTestFile(toPosixPath(storedPath)),
- errors: record.errors ?? [],
- /** The file node itself, so the viewer can navigate to it as a symbol. */
- id: fileNode?.id ?? null,
- },
- /**
- * Calls made at the top level of the file, outside every definition.
- * Counted as distinct call SITES — `(target, line, column)` — so a call
- * two resolvers both recorded is one thing to read, not two.
- */
- topLevel: {
- calls: new Set(topLevelEdges.map((e) => `${e.target}:${e.line ?? 0}:${e.column ?? 0}`)).size,
- },
- /** The file changed on disk since it was indexed — the outline's lines may be shifted. */
- drift: hasDriftedOnDisk(projectRoot, storedPath, record),
- outline: wireList(outline, outlineTotal),
- imports: wireList(imports.slice(0, MAX_IMPORT_FILES), imports.length),
- importedBy: wireList(importedBy.slice(0, MAX_IMPORT_FILES), importedBy.length),
- unresolvedImports,
- /**
- * The broader relationship: every file this one has a cross-file edge into,
- * and every file that has one into it — calls and type references, not just
- * import statements. `imports` alone understates both, badly in languages
- * where symbols resolve without an explicit import.
- */
- dependencies: cg.getFileDependencies(storedPath).map(toPosixPath).sort(),
- dependents: cg.getFileDependents(storedPath).map(toPosixPath).sort(),
- };
- }
- /**
- * A file's symbols in source order, nested under their container.
- *
- * Extracted so the whole-file source view (`/api/filecode`) draws the same rows
- * as the outline view rather than a second, subtly different reading of the
- * same `contains` edges — an outline rail whose line numbers disagreed with the
- * source beside it would be worse than no rail.
- *
- * Four batched queries whatever the file holds: its nodes are already in hand,
- * their `contains` edges, and fan-in / fan-out for the whole set at once.
- *
- * @returns the capped rows and the TRUE symbol count, which is what a header
- * has to print — see `wireList`.
- */
- export function buildOutlineEntries(
- cg: CodeGraph,
- nodes: readonly Node[]
- ): { entries: WireOutlineEntry[]; total: number } {
- const nodeIds = nodes.map((n) => n.id);
- const inThisFile = new Set(nodeIds);
- const fileNodeId = nodes.find((n) => n.kind === 'file')?.id;
- const parentOf = new Map<string, string>();
- for (const edge of cg.getOutgoingEdgesFrom(nodeIds, ['contains'])) {
- // Only nesting *within* this file: a `contains` edge reaching out of it is
- // not something a file outline can draw.
- if (inThisFile.has(edge.target) && !parentOf.has(edge.target)) {
- parentOf.set(edge.target, edge.source);
- }
- }
- const fanIn = cg.getFanIn(nodeIds);
- const fanOut = cg.getFanOut(nodeIds);
- const outlineNodes = nodes
- // The file node is the subject of the screen, not a row in its own outline;
- // import declarations get their own rail and would otherwise be most of it.
- .filter((n) => n.kind !== 'file' && n.kind !== 'import')
- .sort((a, b) => a.startLine - b.startLine || a.name.localeCompare(b.name));
- const entries: WireOutlineEntry[] = outlineNodes.slice(0, MAX_OUTLINE_NODES).map((node) => ({
- ...toNodeRef(node),
- parentId: resolveOutlineParent(node.id, parentOf, fileNodeId),
- depth: depthOf(node.id, parentOf, fileNodeId),
- fanIn: fanIn.get(node.id) ?? 0,
- fanOut: fanOut.get(node.id) ?? 0,
- }));
- return { entries, total: outlineNodes.length };
- }
- /**
- * The outline parent of a symbol: its container within the file, or null when
- * that container is the file node itself (a top-level symbol has no parent row).
- */
- function resolveOutlineParent(
- id: string,
- parentOf: Map<string, string>,
- fileNodeId: string | undefined
- ): string | null {
- const parent = parentOf.get(id);
- if (!parent || parent === fileNodeId) return null;
- return parent;
- }
- function depthOf(
- id: string,
- parentOf: Map<string, string>,
- fileNodeId: string | undefined
- ): number {
- let depth = 0;
- let current = id;
- // Bounded by the number of links so a cyclic `contains` chain — which should
- // be impossible, but is one bad index away — cannot spin here.
- for (let guard = 0; guard < 32; guard++) {
- const parent = parentOf.get(current);
- if (!parent || parent === fileNodeId) return depth;
- depth++;
- current = parent;
- }
- return depth;
- }
- /** Fold edges into one row per file at the far end, ordered by symbol count. */
- function groupByFile(
- edges: readonly Edge[],
- endpoint: (edge: Edge) => string,
- nodes: Map<string, Node>
- ): WireImportRow[] {
- const byFile = new Map<string, Map<string, Node>>();
- for (const edge of edges) {
- const node = nodes.get(endpoint(edge));
- if (!node) continue;
- const file = toPosixPath(node.filePath);
- let bucket = byFile.get(file);
- if (!bucket) {
- bucket = new Map<string, Node>();
- byFile.set(file, bucket);
- }
- bucket.set(node.id, node);
- }
- return [...byFile.entries()]
- .map(([file, symbols]) => {
- const ordered = [...symbols.values()].sort(
- (a, b) => a.startLine - b.startLine || a.name.localeCompare(b.name)
- );
- return {
- file,
- test: isTestFile(file),
- symbols: ordered.slice(0, MAX_SYMBOLS_PER_IMPORT).map((n) => ({
- id: n.id,
- name: n.name,
- kind: n.kind,
- line: n.startLine,
- })),
- symbolCount: ordered.length,
- };
- })
- .sort((a, b) => b.symbolCount - a.symbolCount || a.file.localeCompare(b.file));
- }
- function unresolvedImportsOf(
- cg: CodeGraph,
- fileNodeId: string
- ): Array<{ name: string; line: number }> {
- try {
- return cg
- .getUnresolvedReferencesFrom(fileNodeId)
- .filter((ref) => ref.referenceKind === 'imports')
- .sort((a, b) => a.line - b.line || a.referenceName.localeCompare(b.referenceName))
- .slice(0, MAX_UNRESOLVED_IMPORTS)
- .map((ref) => ({ name: ref.referenceName, line: ref.line }));
- } catch {
- return [];
- }
- }
|