symbol-model.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. /**
  2. * Everything the Symbol view derives from one `/api/node` payload, as plain
  3. * functions over plain data.
  4. *
  5. * None of this touches the DOM or Svelte's reactivity. The screen's hard parts
  6. * — which lines get a port, which callee row sits at which height, which call
  7. * site is a link — are all decisions about the payload, and keeping them here
  8. * means they can be reasoned about (and tested) without a browser.
  9. *
  10. * Design spec §3.2.
  11. */
  12. import type {
  13. WireEdge,
  14. WireMember,
  15. WireOutsideIndex,
  16. WireRelation,
  17. WireSymbolPayload,
  18. } from './api';
  19. /* ------------------------------------------------------------- constants -- */
  20. /** Bodies at or under this are shown whole (design spec §3.2). */
  21. export const FULL_BODY_LINES = 260;
  22. /** Above that, the head is shown in full before the windows begin. */
  23. export const HEAD_LINES = 80;
  24. /** Lines of context kept either side of a call site in a windowed body. */
  25. export const WINDOW_CONTEXT = 4;
  26. /** Two windows closer than this merge — a 1-line gap row costs more than it saves. */
  27. const WINDOW_MERGE_GAP = 2;
  28. /** Windows in one body. Past this the body is a listing, not a reading. */
  29. const MAX_WINDOWS = 30;
  30. /** A container bigger than this shows its outline instead of its body. */
  31. export const CONTAINER_BODY_LINES = 80;
  32. /** Kinds that hold other symbols — they get an outline, not a 700-line body. */
  33. export const CONTAINER_KINDS = new Set([
  34. 'file',
  35. 'module',
  36. 'namespace',
  37. 'class',
  38. 'struct',
  39. 'interface',
  40. 'trait',
  41. 'protocol',
  42. 'enum',
  43. 'union',
  44. ]);
  45. /** Kinds whose outline rows are dimmed: data, not behaviour. */
  46. const QUIET_MEMBER_KINDS = new Set(['property', 'field', 'enum_member', 'constant', 'variable']);
  47. /* ----------------------------------------------------------------- words -- */
  48. /**
  49. * What an edge is called in a rail's meta line.
  50. *
  51. * `calls` returns '' deliberately: it is the default reading of the whole
  52. * screen, and labelling every row "calls" is noise that hides the rows where
  53. * the relationship is something else.
  54. */
  55. export function edgeWord(edge: WireEdge): string {
  56. switch (edge.kind) {
  57. case 'calls':
  58. return '';
  59. case 'instantiates':
  60. return 'creates';
  61. case 'references':
  62. return edge.valueRef ? 'passes as value' : 'uses type';
  63. default:
  64. return edge.kind;
  65. }
  66. }
  67. /** The distinct edge words for a relation, in first-seen order, blanks dropped. */
  68. export function relationWords(relation: WireRelation): string[] {
  69. const words: string[] = [];
  70. for (const edge of relation.edges) {
  71. const word = edgeWord(edge);
  72. if (word && !words.includes(word)) words.push(word);
  73. }
  74. return words;
  75. }
  76. /** The synthesizer that produced this relation's edge, when one did. */
  77. export function synthesizedBy(relation: WireRelation): string | null {
  78. if (!relation.synthesized) return null;
  79. const edge = relation.edges.find((e) => e.provenance === 'heuristic');
  80. return edge?.synthesizedBy ?? edge?.via ?? 'synthesized';
  81. }
  82. export function basename(path: string): string {
  83. return path.slice(path.lastIndexOf('/') + 1);
  84. }
  85. /** The trailing segment of a dotted/qualified name — what appears in the source. */
  86. export function lastSegment(name: string): string {
  87. const dot = name.lastIndexOf('.');
  88. return dot < 0 ? name : name.slice(dot + 1);
  89. }
  90. /* --------------------------------------------------------------- windows -- */
  91. export interface SourceWindow {
  92. /** 1-based file line of `lines[0]`. */
  93. start: number;
  94. lines: string[];
  95. }
  96. export interface CodeBlock {
  97. windows: SourceWindow[];
  98. /** Lines skipped between window i and i+1 — the "⋯ N lines without calls" rows. */
  99. gapsAfter: number[];
  100. /** Lines dropped after the last window, if the body did not run to its end. */
  101. tailGap: number;
  102. /** The body was shown whole. */
  103. whole: boolean;
  104. }
  105. /**
  106. * Cut a long body down to its head plus the neighbourhood of every call site.
  107. *
  108. * The rule is the one the prototype established and the screenshots pin: a
  109. * body of {@link FULL_BODY_LINES} or fewer is shown whole, and a longer one
  110. * keeps its first {@link HEAD_LINES} lines — where the signature, the guards
  111. * and the shape of the function live — plus ±{@link WINDOW_CONTEXT} lines
  112. * around each call, because a call site with no context is a name, not code.
  113. *
  114. * @param startLine 1-based first line of the symbol
  115. * @param lines the body's source, `lines[0]` being `startLine`
  116. * @param callLines every line in the body that makes an outgoing edge
  117. */
  118. export function buildCodeBlock(
  119. startLine: number,
  120. lines: readonly string[],
  121. callLines: readonly number[]
  122. ): CodeBlock {
  123. const endLine = startLine + lines.length - 1;
  124. const slice = (from: number, to: number): SourceWindow => ({
  125. start: from,
  126. lines: lines.slice(from - startLine, to - startLine + 1),
  127. });
  128. if (lines.length <= FULL_BODY_LINES) {
  129. return {
  130. windows: lines.length > 0 ? [slice(startLine, endLine)] : [],
  131. gapsAfter: [],
  132. tailGap: 0,
  133. whole: true,
  134. };
  135. }
  136. const headEnd = Math.min(endLine, startLine + HEAD_LINES - 1);
  137. const ranges: Array<[number, number]> = [[startLine, headEnd]];
  138. const sites = [...new Set(callLines)]
  139. .filter((line) => line > headEnd && line <= endLine)
  140. .sort((a, b) => a - b);
  141. for (const line of sites) {
  142. ranges.push([
  143. Math.max(startLine, line - WINDOW_CONTEXT),
  144. Math.min(endLine, line + WINDOW_CONTEXT),
  145. ]);
  146. }
  147. const merged: Array<[number, number]> = [];
  148. for (const range of ranges) {
  149. const last = merged[merged.length - 1];
  150. if (last && range[0] <= last[1] + WINDOW_MERGE_GAP) last[1] = Math.max(last[1], range[1]);
  151. else merged.push([...range] as [number, number]);
  152. }
  153. const kept = merged.slice(0, MAX_WINDOWS);
  154. const windows = kept.map(([from, to]) => slice(from, to));
  155. const gapsAfter = kept.slice(0, -1).map((range, i) => (kept[i + 1] as [number, number])[0] - range[1] - 1);
  156. const lastEnd = kept[kept.length - 1]?.[1] ?? endLine;
  157. return { windows, gapsAfter, tailGap: Math.max(0, endLine - lastEnd), whole: false };
  158. }
  159. /* ------------------------------------------------------------------ refs -- */
  160. /** One identifier in the body that the graph has something to say about. */
  161. export interface LineRef {
  162. /** The identifier as it appears in the source — what the token must match. */
  163. ident: string;
  164. /** 0-based column the edge was recorded at, or null when it carries none. */
  165. col: number | null;
  166. /** Target node id, or null for a reference that leaves the index. */
  167. targetId: string | null;
  168. uncertain: boolean;
  169. /** No node behind it — rendered as text with a soft underline, not a link. */
  170. outside: boolean;
  171. title: string;
  172. }
  173. /**
  174. * Which identifiers on which lines are edges, keyed by 1-based line.
  175. *
  176. * Includes the type references (`uses types …` in the header) so a line that
  177. * only names a type still gets its port: the port's claim is "something leaves
  178. * the graph from this line", and a type reference does.
  179. */
  180. export function refsByLine(payload: WireSymbolPayload): Map<number, LineRef[]> {
  181. const byLine = new Map<number, LineRef[]>();
  182. const add = (line: number, ref: LineRef): void => {
  183. const bucket = byLine.get(line);
  184. if (bucket) bucket.push(ref);
  185. else byLine.set(line, [ref]);
  186. };
  187. for (const relation of [...payload.outgoing.items, ...payload.typesUsed]) {
  188. for (const edge of relation.edges) {
  189. if (!edge.line) continue;
  190. const word = edgeWord(edge);
  191. add(edge.line, {
  192. ident: lastSegment(relation.node.name),
  193. col: typeof edge.col === 'number' ? edge.col : null,
  194. targetId: relation.node.id,
  195. uncertain: relation.uncertain,
  196. outside: false,
  197. title:
  198. `${word || 'calls'} ${relation.node.qualifiedName} — ${relation.node.file}:${relation.node.line}` +
  199. (edge.confidence != null ? ` · confidence ${edge.confidence}` : '') +
  200. (edge.resolvedBy ? ` · resolved by ${edge.resolvedBy}` : ''),
  201. });
  202. }
  203. }
  204. for (const ref of outsideRefs(payload.outsideIndex)) add(ref.line, ref.ref);
  205. return byLine;
  206. }
  207. /**
  208. * The lines a long body is windowed around.
  209. *
  210. * Only edges that reach something IN the graph count. An unresolved reference
  211. * still gets its port and its soft underline where it happens to be on screen,
  212. * but it must not open a window of its own: a function with 170 calls into
  213. * `console`, `Promise` and `fs` would window around nearly every line and the
  214. * head-plus-windows rule would buy nothing.
  215. */
  216. export function graphCallLines(payload: WireSymbolPayload): number[] {
  217. const lines = new Set<number>();
  218. for (const relation of [...payload.outgoing.items, ...payload.typesUsed]) {
  219. for (const line of relation.lines) lines.add(line);
  220. }
  221. return [...lines].sort((a, b) => a - b);
  222. }
  223. /**
  224. * References with no node behind them, as line refs.
  225. *
  226. * The samples are raw resolver bookkeeping, so anything that is not a plain
  227. * identifier — a whole arrow function captured as a "name", a receiver
  228. * expression — is dropped rather than searched for in the line: a ref that
  229. * cannot match a token would silently claim the wrong one.
  230. */
  231. function outsideRefs(outside: WireOutsideIndex): Array<{ line: number; ref: LineRef }> {
  232. const out: Array<{ line: number; ref: LineRef }> = [];
  233. for (const sample of outside.samples) {
  234. if (!sample.line) continue;
  235. const ident = lastSegment(sample.name ?? '');
  236. if (!/^[A-Za-z_$][\w$]*$/.test(ident)) continue;
  237. out.push({
  238. line: sample.line,
  239. ref: {
  240. ident,
  241. col: typeof sample.col === 'number' ? sample.col : null,
  242. targetId: null,
  243. uncertain: false,
  244. outside: true,
  245. title: `${sample.name} is not in the index — nothing here resolves it`,
  246. },
  247. });
  248. }
  249. return out;
  250. }
  251. /**
  252. * Decide which token on a line each ref refers to.
  253. *
  254. * A line can name the same identifier twice (`b.render(a.render())`) and the
  255. * recorded column points at the start of the *expression*, not at the callee's
  256. * own name, so an exact column hit is the exception rather than the rule. The
  257. * ladder — containing token, then first token at or after the column, then any
  258. * unclaimed one, then the last — is what makes `this.mutex.withLock(…)` mark
  259. * `withLock` instead of `this`.
  260. *
  261. * @returns token index → the ref that claimed it
  262. */
  263. export function assignRefs(
  264. tokens: ReadonlyArray<{ cls: string; text: string; col: number }>,
  265. refs: readonly LineRef[]
  266. ): Map<number, LineRef> {
  267. const claimed = new Map<number, LineRef>();
  268. for (const ref of refs) {
  269. const candidates: number[] = [];
  270. tokens.forEach((token, index) => {
  271. if (token.cls === 'ident' && token.text === ref.ident) candidates.push(index);
  272. });
  273. if (candidates.length === 0) continue;
  274. let pick: number | undefined;
  275. if (ref.col !== null) {
  276. const col = ref.col;
  277. pick = candidates.find((i) => {
  278. const t = tokens[i] as { text: string; col: number };
  279. return t.col <= col && col < t.col + t.text.length;
  280. });
  281. if (pick === undefined) pick = candidates.find((i) => (tokens[i] as { col: number }).col >= col);
  282. }
  283. if (pick === undefined) pick = candidates.find((i) => !claimed.has(i));
  284. if (pick === undefined) pick = candidates[candidates.length - 1];
  285. if (pick === undefined || claimed.has(pick)) continue;
  286. claimed.set(pick, ref);
  287. }
  288. return claimed;
  289. }
  290. /* ------------------------------------------------------------ right rail -- */
  291. export interface CalleeRow {
  292. relation: WireRelation;
  293. /** First call-site line — the height the row wants to sit at. */
  294. anchor: number | null;
  295. /** Distinct call-site lines; `×N` appears when there is more than one. */
  296. lines: number[];
  297. words: string[];
  298. via: string | null;
  299. }
  300. export interface CalleeRailModel {
  301. rows: CalleeRow[];
  302. uncertain: CalleeRow[];
  303. /** Callee groups the API had to cap away. */
  304. hiddenGroups: number;
  305. outsideCalls: number;
  306. outsideTypeRefs: number;
  307. }
  308. export function buildCalleeRail(payload: WireSymbolPayload): CalleeRailModel {
  309. const rows: CalleeRow[] = [];
  310. const uncertain: CalleeRow[] = [];
  311. for (const relation of payload.outgoing.items) {
  312. const row: CalleeRow = {
  313. relation,
  314. anchor: relation.lines[0] ?? null,
  315. lines: relation.lines,
  316. words: relationWords(relation),
  317. via: synthesizedBy(relation),
  318. };
  319. if (relation.uncertain) uncertain.push(row);
  320. else rows.push(row);
  321. }
  322. const typeRefs = payload.outsideIndex.byKind['references'] ?? 0;
  323. return {
  324. rows,
  325. uncertain,
  326. hiddenGroups: payload.outgoing.total - payload.outgoing.shown,
  327. outsideCalls: Math.max(0, payload.outsideIndex.total - typeRefs),
  328. outsideTypeRefs: typeRefs,
  329. };
  330. }
  331. /* ------------------------------------------------------------- left rail -- */
  332. export interface CallerRow {
  333. relation: WireRelation;
  334. words: string[];
  335. /** Call-site lines in the CALLER's file — the `:4657` chips. */
  336. lines: number[];
  337. via: string | null;
  338. }
  339. export interface CallerFileGroup {
  340. file: string;
  341. /** True for the focal symbol's own file, which is labelled "same file". */
  342. same: boolean;
  343. rows: CallerRow[];
  344. }
  345. export interface CallerRailModel {
  346. groups: CallerFileGroup[];
  347. uncertain: CallerRow[];
  348. tests: { rows: CallerRow[]; calls: number; files: string[] };
  349. /** Distinct callers, including the ones folded into tests and uncertain. */
  350. total: number;
  351. hiddenGroups: number;
  352. }
  353. /**
  354. * The left rail: callers grouped by file, with tests and name-only guesses
  355. * folded away.
  356. *
  357. * The folds are not "hide the boring ones" — they are the two cases where a
  358. * long list would drown the answer. Tests are usually the largest group and
  359. * the least surprising ("of course the test file calls it"), and an uncertain
  360. * caller is a guess the reader should be able to see marked as one rather than
  361. * mixed into the same list as a resolved call. Both carry their counts.
  362. */
  363. export function buildCallerRail(payload: WireSymbolPayload): CallerRailModel {
  364. const focalFile = payload.node.file;
  365. const byFile = new Map<string, CallerRow[]>();
  366. const uncertain: CallerRow[] = [];
  367. const testRows: CallerRow[] = [];
  368. for (const relation of payload.incoming.items) {
  369. const row: CallerRow = {
  370. relation,
  371. words: relationWords(relation),
  372. lines: relation.lines,
  373. via: synthesizedBy(relation),
  374. };
  375. // Uncertainty wins over test-ness: a name-only guess is a claim about the
  376. // edge, and burying it in the tests fold would present it as established.
  377. if (relation.uncertain) {
  378. uncertain.push(row);
  379. continue;
  380. }
  381. if (relation.node.test) {
  382. testRows.push(row);
  383. continue;
  384. }
  385. const bucket = byFile.get(relation.node.file);
  386. if (bucket) bucket.push(row);
  387. else byFile.set(relation.node.file, [row]);
  388. }
  389. const groups: CallerFileGroup[] = [...byFile.entries()]
  390. .map(([file, rows]) => ({ file, same: file === focalFile, rows }))
  391. .sort((a, b) => (a.same ? -1 : b.same ? 1 : a.file.localeCompare(b.file)));
  392. return {
  393. groups,
  394. uncertain,
  395. tests: {
  396. rows: testRows,
  397. calls: testRows.reduce((sum, row) => sum + row.relation.edgeCount, 0),
  398. files: [...new Set(testRows.map((row) => row.relation.node.file))].sort(),
  399. },
  400. total: payload.incoming.total,
  401. hiddenGroups: payload.incoming.total - payload.incoming.shown,
  402. };
  403. }
  404. /* ----------------------------------------------------------- connectors -- */
  405. /** One hairline from a gutter port to a callee row. Geometry comes from the view. */
  406. export interface Connector {
  407. /** SVG path data — a single cubic from the port to the row. */
  408. d: string;
  409. targetId: string;
  410. uncertain: boolean;
  411. /** Synthesized rather than parsed — dynamic dispatch, drawn dashed. */
  412. heuristic: boolean;
  413. /** The edge the reader arrived by. */
  414. origin: boolean;
  415. }
  416. /* -------------------------------------------------------------- outline -- */
  417. export interface OutlineRow {
  418. member: WireMember;
  419. nested: boolean;
  420. dimmed: boolean;
  421. }
  422. export function buildOutline(payload: WireSymbolPayload): OutlineRow[] {
  423. return payload.members.items.map((member) => ({
  424. member,
  425. nested: member.depth > 1,
  426. dimmed: QUIET_MEMBER_KINDS.has(member.kind),
  427. }));
  428. }
  429. /* ------------------------------------------------------------- decisions -- */
  430. /**
  431. * Whether this symbol's body is worth drawing at all.
  432. *
  433. * A 700-line class body is a list of members with braces between them: the
  434. * outline says the same thing in 20 rows and lets the reader pick one. Below
  435. * {@link CONTAINER_BODY_LINES} the body IS the useful view of a container, so
  436. * both are shown.
  437. */
  438. export function showsBody(kind: string, lines: number): boolean {
  439. return !(CONTAINER_KINDS.has(kind) && lines > CONTAINER_BODY_LINES);
  440. }
  441. /** The kind word and the modifiers that belong beside a symbol's name. */
  442. export function kindPhrase(node: {
  443. kind: string;
  444. async?: boolean;
  445. static?: boolean;
  446. abstract?: boolean;
  447. visibility?: string;
  448. }): string {
  449. const parts = [node.kind === 'type_alias' ? 'type' : node.kind.replace(/_/g, ' ')];
  450. if (node.async) parts.push('async');
  451. if (node.static) parts.push('static');
  452. if (node.abstract) parts.push('abstract');
  453. if (node.visibility && node.visibility !== 'public') parts.push(node.visibility);
  454. return parts.join(' · ');
  455. }
  456. /** "1 caller" / "12 callers" — the counts sit next to too many nouns to inline. */
  457. export function plural(count: number, one: string, many = `${one}s`): string {
  458. return `${count} ${count === 1 ? one : many}`;
  459. }