search.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. /**
  2. * `GET /api/search?q=` — the search palette's one round-trip.
  3. *
  4. * Three lookups feed it, because no single one covers what a person types into
  5. * a palette:
  6. *
  7. * - `getNodesByNameSubstring` — case-insensitive, catches the exact, prefix and
  8. * mid-name matches (`profileInfo` inside `getProfileInfoV2`) that FTS tokens
  9. * cannot.
  10. * - `searchNodes` — FTS5, plus the engine's own LIKE and fuzzy fallbacks, and
  11. * the `kind:` / `lang:` / `path:` / `name:` filter grammar for free.
  12. * - `getNodesByName` — every symbol with exactly that name, uncapped, so a
  13. * heavily-overloaded name never loses its definitions below a search cut.
  14. *
  15. * They are then merged and ranked by HOW the name matched — exact, prefix,
  16. * substring, qualified name, file path — rather than by any single engine's
  17. * score, because those scores are not comparable with each other. Results are
  18. * grouped by kind: "did I mean the class or the method" is the question a
  19. * palette actually has to answer.
  20. */
  21. import type { CodeGraph } from '../../index';
  22. import type { Node, NodeKind } from '../../types';
  23. import { parseQuery, type ParsedQuery } from '../../search/query-parser';
  24. import { intParam, optionalTextParam } from './respond';
  25. import { toNodeRef, wireList, type WireNodeRef } from './wire';
  26. /** How a result's text matched the query. Also the primary sort key. */
  27. export type MatchKind = 'exact' | 'prefix' | 'substring' | 'qualified' | 'file' | 'related';
  28. const MATCH_RANK: Record<MatchKind, number> = {
  29. exact: 0,
  30. prefix: 1,
  31. substring: 2,
  32. qualified: 3,
  33. file: 4,
  34. // Matched by FTS through a signature, docstring or fuzzy neighbour — real,
  35. // but never what someone typing a name is looking for first.
  36. related: 5,
  37. };
  38. /** Candidates pulled from each source before ranking trims to `limit`. */
  39. const CANDIDATE_POOL = 400;
  40. export interface WireSearchResult extends WireNodeRef {
  41. matchKind: MatchKind;
  42. }
  43. /**
  44. * Tie-break inside a match tier: the kinds someone navigates to, before the
  45. * kinds that merely mention a name.
  46. */
  47. function kindRank(kind: NodeKind): number {
  48. switch (kind) {
  49. case 'function':
  50. case 'method':
  51. case 'class':
  52. case 'component':
  53. case 'interface':
  54. case 'struct':
  55. case 'trait':
  56. case 'protocol':
  57. case 'enum':
  58. case 'union':
  59. case 'type_alias':
  60. case 'route':
  61. return 0;
  62. case 'constant':
  63. case 'property':
  64. case 'field':
  65. case 'variable':
  66. case 'enum_member':
  67. return 1;
  68. case 'file':
  69. case 'module':
  70. case 'namespace':
  71. return 2;
  72. default:
  73. // import / export / parameter — a mention, not a definition.
  74. return 3;
  75. }
  76. }
  77. function classify(node: Node, needle: string): MatchKind | null {
  78. const name = node.name.toLowerCase();
  79. if (name === needle) return 'exact';
  80. if (name.startsWith(needle)) return 'prefix';
  81. if (name.includes(needle)) return 'substring';
  82. if (node.qualifiedName.toLowerCase().includes(needle)) return 'qualified';
  83. if (node.filePath.toLowerCase().replace(/\\/g, '/').includes(needle)) return 'file';
  84. return null;
  85. }
  86. export function buildSearch(cg: CodeGraph, query: URLSearchParams): unknown {
  87. const raw = optionalTextParam(query, 'q');
  88. const limit = intParam(query, 'limit', { min: 1, max: 200, default: 60 });
  89. // An empty search box is the palette's resting state, not a mistake — it
  90. // answers with nothing rather than with an error the viewer has to special-
  91. // case. A MISSING `q` is still a 400: that is a caller bug.
  92. if (raw.trim() === '') return emptySearch(raw);
  93. // The filter grammar (`kind:function auth`) belongs to `searchNodes`; the
  94. // name lookups only ever want the free-text part of what was typed.
  95. const parsed = parseQuery(raw);
  96. const text = parsed.text.trim();
  97. const needle = text.toLowerCase();
  98. const candidates = new Map<string, Node>();
  99. const remember = (node: Node): void => {
  100. if (!candidates.has(node.id)) candidates.set(node.id, node);
  101. };
  102. if (text.length > 0) {
  103. for (const node of cg.getNodesByName(text)) remember(node);
  104. for (const node of cg.getNodesByNameSubstring(text, { limit: CANDIDATE_POOL })) remember(node);
  105. }
  106. for (const result of cg.searchNodes(raw, { limit: CANDIDATE_POOL })) remember(result.node);
  107. const scored: Array<{ node: Node; match: MatchKind }> = [];
  108. for (const node of candidates.values()) {
  109. // `searchNodes` applies the filter grammar to its own results, but the two
  110. // direct name lookups above know nothing about it — so `kind:class Cache`
  111. // would otherwise pull in `CacheKey` and every `Cache` method through the
  112. // substring lookup. The gate belongs to the merged candidate set.
  113. if (!matchesFilters(node, parsed)) continue;
  114. // An empty text portion means the query was pure filters (`kind:route`);
  115. // everything `searchNodes` returned already satisfies them, so there is no
  116. // name match to grade and every row is equally "related".
  117. const match = needle.length === 0 ? 'related' : classify(node, needle) ?? 'related';
  118. scored.push({ node, match });
  119. }
  120. scored.sort((a, b) => {
  121. const byMatch = MATCH_RANK[a.match] - MATCH_RANK[b.match];
  122. if (byMatch !== 0) return byMatch;
  123. const byKind = kindRank(a.node.kind) - kindRank(b.node.kind);
  124. if (byKind !== 0) return byKind;
  125. // Production code before tests and fixtures: both are real answers, but one
  126. // of them is the one someone searching for a symbol usually means.
  127. const aTest = isTestPath(a.node.filePath);
  128. const bTest = isTestPath(b.node.filePath);
  129. if (aTest !== bTest) return aTest ? 1 : -1;
  130. // Shorter names are closer to what was typed (`get` before `getOrCreate`).
  131. const byLength = a.node.name.length - b.node.name.length;
  132. if (byLength !== 0) return byLength;
  133. return (
  134. a.node.filePath.localeCompare(b.node.filePath) || a.node.startLine - b.node.startLine
  135. );
  136. });
  137. const top = scored.slice(0, limit);
  138. // One bounded lookup for the whole page of results, so a generated stub
  139. // reads as one at a glance instead of after a click.
  140. const isGenerated = cg.generatedFilePredicate(top.map(({ node }) => node.filePath));
  141. const results: WireSearchResult[] = top.map(({ node, match }) => {
  142. const result: WireSearchResult = { ...toNodeRef(node), matchKind: match };
  143. if (isGenerated(node.filePath)) result.generated = true;
  144. return result;
  145. });
  146. // Groups keep the ranked order: a group appears where its best result did, so
  147. // flattening the groups reproduces the flat ranking for keyboard navigation.
  148. const groups: Array<{ kind: NodeKind; count: number; items: WireSearchResult[] }> = [];
  149. const byKind = new Map<NodeKind, WireSearchResult[]>();
  150. for (const result of results) {
  151. const bucket = byKind.get(result.kind);
  152. if (bucket) {
  153. bucket.push(result);
  154. } else {
  155. const created = [result];
  156. byKind.set(result.kind, created);
  157. groups.push({ kind: result.kind, count: 0, items: created });
  158. }
  159. }
  160. for (const group of groups) group.count = group.items.length;
  161. return {
  162. query: raw,
  163. text,
  164. filters: {
  165. kinds: parsed.kinds,
  166. languages: parsed.languages,
  167. paths: parsed.pathFilters,
  168. names: parsed.nameFilters,
  169. },
  170. results: wireList(results, scored.length),
  171. groups,
  172. };
  173. }
  174. /**
  175. * Deliberately a plain path check rather than the engine's `isTestFile`: this
  176. * is a ranking nudge inside one tier, and `isTestFile` also treats `examples/`,
  177. * `benchmarks/` and `fixtures/` as tests — pushing a legitimately-searched
  178. * example below an unrelated production symbol.
  179. */
  180. function isTestPath(filePath: string): boolean {
  181. const lower = filePath.toLowerCase().replace(/\\/g, '/');
  182. return (
  183. /(^|\/)(tests?|specs?|__tests__)\//.test(lower) ||
  184. /[._-](test|tests|spec|specs)\.[a-z0-9]+$/.test(lower)
  185. );
  186. }
  187. /**
  188. * The hard gate the `kind:` / `lang:` / `path:` / `name:` grammar asks for.
  189. *
  190. * Deliberately the same predicates `searchNodes` uses internally — kinds and
  191. * languages exact, paths and names case-insensitive substrings, each list OR'd
  192. * within itself and AND'd across lists — so a filtered search means the same
  193. * thing whichever lookup a result came from.
  194. */
  195. function matchesFilters(node: Node, parsed: ParsedQuery): boolean {
  196. if (parsed.kinds.length > 0 && !parsed.kinds.includes(node.kind)) return false;
  197. if (parsed.languages.length > 0 && !parsed.languages.includes(node.language)) return false;
  198. if (parsed.pathFilters.length > 0) {
  199. const file = node.filePath.toLowerCase();
  200. if (!parsed.pathFilters.some((p) => file.includes(p.toLowerCase()))) return false;
  201. }
  202. if (parsed.nameFilters.length > 0) {
  203. const name = node.name.toLowerCase();
  204. if (!parsed.nameFilters.some((n) => name.includes(n.toLowerCase()))) return false;
  205. }
  206. return true;
  207. }
  208. /** The resting state of the palette: the shape of a real answer, with nothing in it. */
  209. function emptySearch(raw: string): unknown {
  210. return {
  211. query: raw,
  212. text: '',
  213. filters: { kinds: [], languages: [], paths: [], names: [] },
  214. results: wireList<WireSearchResult>([], 0),
  215. groups: [],
  216. };
  217. }