file.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. /**
  2. * `GET /api/file/<path>` — the File view in one round-trip.
  3. *
  4. * Three panes: what imports this file, the file's own outline in source order,
  5. * and what this file imports. All of it comes from four batched queries — the
  6. * file's nodes, their `contains` edges, their `imports` edges in each
  7. * direction — never a query per symbol.
  8. *
  9. * Two things worth knowing about `imports` edges before reading the mapping
  10. * below. First, they point at the *symbol* that was imported, not at the file
  11. * holding it, so file granularity means mapping each edge's endpoint through
  12. * `nodes.file_path`. Second, plenty of them stay inside one file (an import
  13. * declaration is a node in the importing file), so the same-file ones have to
  14. * be dropped or every file appears to import itself.
  15. *
  16. * The rails would still read as broken without the third piece: imports that
  17. * never resolved. A file importing `react`, `fs` and one local module would
  18. * otherwise show a single row, silently implying the other two do not exist.
  19. * They are listed separately, as what they are — outside the index.
  20. */
  21. import type { CodeGraph } from '../../index';
  22. import type { Edge, Node } from '../../types';
  23. import { isTestFile } from '../../search/query-utils';
  24. import { hasDriftedOnDisk, resolveRequestedFile } from './source';
  25. import {
  26. MAX_IMPORT_FILES,
  27. MAX_OUTLINE_NODES,
  28. toNodeRef,
  29. toPosixPath,
  30. wireList,
  31. type WireNodeRef,
  32. } from './wire';
  33. /** Symbols named per import row before it just counts them. */
  34. const MAX_SYMBOLS_PER_IMPORT = 12;
  35. /** Unresolved imports listed by name. */
  36. const MAX_UNRESOLVED_IMPORTS = 60;
  37. /** A row in the file outline. */
  38. export interface WireOutlineEntry extends WireNodeRef {
  39. /** Containing symbol within this file, or null for a top-level one. */
  40. parentId: string | null;
  41. /** Nesting depth from the top level of the file, starting at 0. */
  42. depth: number;
  43. /** Incoming / outgoing edge counts — the `← in → out` column. */
  44. fanIn: number;
  45. fanOut: number;
  46. }
  47. /** One end of the File view's import rails. */
  48. export interface WireImportRow {
  49. file: string;
  50. test: boolean;
  51. /** Which symbols the edges name, capped. */
  52. symbols: Array<{ id: string; name: string; kind: string; line: number }>;
  53. symbolCount: number;
  54. }
  55. export function buildFile(cg: CodeGraph, projectRoot: string, requested: string): unknown {
  56. // Refusal first, index lookup second — a traversal out of the project is a
  57. // refusal, not "no such file". See `resolveRequestedFile`.
  58. const { record, storedPath } = resolveRequestedFile(cg, projectRoot, requested);
  59. const nodes = cg.getNodesInFile(storedPath);
  60. const nodeIds = nodes.map((n) => n.id);
  61. const inThisFile = new Set(nodeIds);
  62. const nodeKindById = new Map(nodes.map((n) => [n.id, n.kind]));
  63. const fileNode = nodes.find((n) => n.kind === 'file') ?? null;
  64. // ---------------------------------------------------------------------------
  65. // Outline
  66. // ---------------------------------------------------------------------------
  67. const { entries: outline, total: outlineTotal } = buildOutlineEntries(cg, nodes);
  68. // ---------------------------------------------------------------------------
  69. // Import rails
  70. // ---------------------------------------------------------------------------
  71. const importsOut = cg.getOutgoingEdgesFrom(nodeIds, ['imports']);
  72. const importsIn = cg.getIncomingEdgesTo(nodeIds, ['imports']);
  73. const endpointIds = new Set<string>();
  74. for (const edge of importsOut) if (!inThisFile.has(edge.target)) endpointIds.add(edge.target);
  75. for (const edge of importsIn) if (!inThisFile.has(edge.source)) endpointIds.add(edge.source);
  76. const endpoints = cg.getNodesByIds([...endpointIds]);
  77. const imports = groupByFile(
  78. importsOut.filter((e) => !inThisFile.has(e.target)),
  79. (e) => e.target,
  80. endpoints
  81. );
  82. const importedBy = groupByFile(
  83. importsIn.filter((e) => !inThisFile.has(e.source)),
  84. (e) => e.source,
  85. endpoints
  86. );
  87. // Import statements that never resolved — the third-party packages and
  88. // runtime builtins. Attributed to the file node, which is where extraction
  89. // records a file-level import.
  90. const unresolvedImports = fileNode ? unresolvedImportsOf(cg, fileNode.id) : [];
  91. // Whether the file RUNS anything at its top level. Extraction records a
  92. // statement outside any definition as an edge out of the FILE node, so a
  93. // module that only defines things has none and a CLI entry point has many —
  94. // the same signal `/api/entrypoints` ranks on. It is worth a line on this
  95. // screen because the outline cannot show it: top-level code belongs to no
  96. // symbol, so the only way to read it is to open the file node itself.
  97. // A call made while initializing a module-level variable or constant is
  98. // attributed to that name (#693), so those names are top-level code too and
  99. // are counted with the file — the same set `getTopCallingFiles` ranks on.
  100. const moduleLevelValueIds = fileNode
  101. ? cg
  102. .getOutgoingEdgesFrom([fileNode.id], ['contains'])
  103. .map((e) => e.target)
  104. .filter((id) => {
  105. const kind = nodeKindById.get(id);
  106. return kind === 'variable' || kind === 'constant';
  107. })
  108. : [];
  109. const topLevelEdges = fileNode
  110. ? cg.getOutgoingEdgesFrom([fileNode.id, ...moduleLevelValueIds], ['calls', 'instantiates'])
  111. : [];
  112. return {
  113. file: {
  114. path: toPosixPath(storedPath),
  115. language: record.language,
  116. size: record.size,
  117. modifiedAt: record.modifiedAt,
  118. indexedAt: record.indexedAt,
  119. contentHash: record.contentHash,
  120. nodeCount: record.nodeCount,
  121. generated: record.generated === true,
  122. test: isTestFile(toPosixPath(storedPath)),
  123. errors: record.errors ?? [],
  124. /** The file node itself, so the viewer can navigate to it as a symbol. */
  125. id: fileNode?.id ?? null,
  126. },
  127. /**
  128. * Calls made at the top level of the file, outside every definition.
  129. * Counted as distinct call SITES — `(target, line, column)` — so a call
  130. * two resolvers both recorded is one thing to read, not two.
  131. */
  132. topLevel: {
  133. calls: new Set(topLevelEdges.map((e) => `${e.target}:${e.line ?? 0}:${e.column ?? 0}`)).size,
  134. },
  135. /** The file changed on disk since it was indexed — the outline's lines may be shifted. */
  136. drift: hasDriftedOnDisk(projectRoot, storedPath, record),
  137. outline: wireList(outline, outlineTotal),
  138. imports: wireList(imports.slice(0, MAX_IMPORT_FILES), imports.length),
  139. importedBy: wireList(importedBy.slice(0, MAX_IMPORT_FILES), importedBy.length),
  140. unresolvedImports,
  141. /**
  142. * The broader relationship: every file this one has a cross-file edge into,
  143. * and every file that has one into it — calls and type references, not just
  144. * import statements. `imports` alone understates both, badly in languages
  145. * where symbols resolve without an explicit import.
  146. */
  147. dependencies: cg.getFileDependencies(storedPath).map(toPosixPath).sort(),
  148. dependents: cg.getFileDependents(storedPath).map(toPosixPath).sort(),
  149. };
  150. }
  151. /**
  152. * A file's symbols in source order, nested under their container.
  153. *
  154. * Extracted so the whole-file source view (`/api/filecode`) draws the same rows
  155. * as the outline view rather than a second, subtly different reading of the
  156. * same `contains` edges — an outline rail whose line numbers disagreed with the
  157. * source beside it would be worse than no rail.
  158. *
  159. * Four batched queries whatever the file holds: its nodes are already in hand,
  160. * their `contains` edges, and fan-in / fan-out for the whole set at once.
  161. *
  162. * @returns the capped rows and the TRUE symbol count, which is what a header
  163. * has to print — see `wireList`.
  164. */
  165. export function buildOutlineEntries(
  166. cg: CodeGraph,
  167. nodes: readonly Node[]
  168. ): { entries: WireOutlineEntry[]; total: number } {
  169. const nodeIds = nodes.map((n) => n.id);
  170. const inThisFile = new Set(nodeIds);
  171. const fileNodeId = nodes.find((n) => n.kind === 'file')?.id;
  172. const parentOf = new Map<string, string>();
  173. for (const edge of cg.getOutgoingEdgesFrom(nodeIds, ['contains'])) {
  174. // Only nesting *within* this file: a `contains` edge reaching out of it is
  175. // not something a file outline can draw.
  176. if (inThisFile.has(edge.target) && !parentOf.has(edge.target)) {
  177. parentOf.set(edge.target, edge.source);
  178. }
  179. }
  180. const fanIn = cg.getFanIn(nodeIds);
  181. const fanOut = cg.getFanOut(nodeIds);
  182. const outlineNodes = nodes
  183. // The file node is the subject of the screen, not a row in its own outline;
  184. // import declarations get their own rail and would otherwise be most of it.
  185. .filter((n) => n.kind !== 'file' && n.kind !== 'import')
  186. .sort((a, b) => a.startLine - b.startLine || a.name.localeCompare(b.name));
  187. const entries: WireOutlineEntry[] = outlineNodes.slice(0, MAX_OUTLINE_NODES).map((node) => ({
  188. ...toNodeRef(node),
  189. parentId: resolveOutlineParent(node.id, parentOf, fileNodeId),
  190. depth: depthOf(node.id, parentOf, fileNodeId),
  191. fanIn: fanIn.get(node.id) ?? 0,
  192. fanOut: fanOut.get(node.id) ?? 0,
  193. }));
  194. return { entries, total: outlineNodes.length };
  195. }
  196. /**
  197. * The outline parent of a symbol: its container within the file, or null when
  198. * that container is the file node itself (a top-level symbol has no parent row).
  199. */
  200. function resolveOutlineParent(
  201. id: string,
  202. parentOf: Map<string, string>,
  203. fileNodeId: string | undefined
  204. ): string | null {
  205. const parent = parentOf.get(id);
  206. if (!parent || parent === fileNodeId) return null;
  207. return parent;
  208. }
  209. function depthOf(
  210. id: string,
  211. parentOf: Map<string, string>,
  212. fileNodeId: string | undefined
  213. ): number {
  214. let depth = 0;
  215. let current = id;
  216. // Bounded by the number of links so a cyclic `contains` chain — which should
  217. // be impossible, but is one bad index away — cannot spin here.
  218. for (let guard = 0; guard < 32; guard++) {
  219. const parent = parentOf.get(current);
  220. if (!parent || parent === fileNodeId) return depth;
  221. depth++;
  222. current = parent;
  223. }
  224. return depth;
  225. }
  226. /** Fold edges into one row per file at the far end, ordered by symbol count. */
  227. function groupByFile(
  228. edges: readonly Edge[],
  229. endpoint: (edge: Edge) => string,
  230. nodes: Map<string, Node>
  231. ): WireImportRow[] {
  232. const byFile = new Map<string, Map<string, Node>>();
  233. for (const edge of edges) {
  234. const node = nodes.get(endpoint(edge));
  235. if (!node) continue;
  236. const file = toPosixPath(node.filePath);
  237. let bucket = byFile.get(file);
  238. if (!bucket) {
  239. bucket = new Map<string, Node>();
  240. byFile.set(file, bucket);
  241. }
  242. bucket.set(node.id, node);
  243. }
  244. return [...byFile.entries()]
  245. .map(([file, symbols]) => {
  246. const ordered = [...symbols.values()].sort(
  247. (a, b) => a.startLine - b.startLine || a.name.localeCompare(b.name)
  248. );
  249. return {
  250. file,
  251. test: isTestFile(file),
  252. symbols: ordered.slice(0, MAX_SYMBOLS_PER_IMPORT).map((n) => ({
  253. id: n.id,
  254. name: n.name,
  255. kind: n.kind,
  256. line: n.startLine,
  257. })),
  258. symbolCount: ordered.length,
  259. };
  260. })
  261. .sort((a, b) => b.symbolCount - a.symbolCount || a.file.localeCompare(b.file));
  262. }
  263. function unresolvedImportsOf(
  264. cg: CodeGraph,
  265. fileNodeId: string
  266. ): Array<{ name: string; line: number }> {
  267. try {
  268. return cg
  269. .getUnresolvedReferencesFrom(fileNodeId)
  270. .filter((ref) => ref.referenceKind === 'imports')
  271. .sort((a, b) => a.line - b.line || a.referenceName.localeCompare(b.referenceName))
  272. .slice(0, MAX_UNRESOLVED_IMPORTS)
  273. .map((ref) => ({ name: ref.referenceName, line: ref.line }));
  274. } catch {
  275. return [];
  276. }
  277. }