query-parser.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. /**
  2. * Field-qualified search query parser.
  3. *
  4. * Splits a raw query like
  5. *
  6. * kind:function name:auth path:src/api authenticate
  7. *
  8. * into structured filters (kind=function, name="auth", path prefix
  9. * "src/api") plus the free-text portion ("authenticate") that goes
  10. * to FTS. Free-text and filters compose: filters narrow the result
  11. * set, FTS scores within the narrowed set.
  12. *
  13. * Recognised fields (case-insensitive, value is the rest until
  14. * whitespace):
  15. *
  16. * kind: one of function|method|class|interface|struct|...
  17. * lang: one of typescript|python|go|... (alias: language:)
  18. * path: case-insensitive substring of file_path
  19. * name: case-insensitive substring of the symbol's name
  20. *
  21. * Unknown field prefixes (e.g. `foo:bar`) are passed through to FTS
  22. * as plain text — that's how someone searching for `TODO:` gets a
  23. * result instead of a parse error.
  24. *
  25. * Quoting:
  26. * kind:function path:"src/some path/with spaces" → handled by stripping
  27. * the surrounding double quotes from the value (single token only,
  28. * no nested escapes).
  29. */
  30. import { NODE_KINDS, LANGUAGES } from '../types';
  31. import type { NodeKind, Language } from '../types';
  32. export interface ParsedQuery {
  33. /** Free-text portion to feed to FTS / LIKE. May be empty. */
  34. text: string;
  35. /** kind: filters (OR'd). Empty when none specified. */
  36. kinds: NodeKind[];
  37. /** lang:/language: filters (OR'd). Empty when none specified. */
  38. languages: Language[];
  39. /** path: filters (OR'd, case-insensitive substring of file_path). Empty when none. */
  40. pathFilters: string[];
  41. /** name: filters (OR'd, case-insensitive substring of node.name). */
  42. nameFilters: string[];
  43. }
  44. // Derived from the canonical `NODE_KINDS` / `LANGUAGES` arrays in
  45. // types.ts so adding a new kind or language doesn't silently fall
  46. // through to plain text here.
  47. const KIND_VALUES: ReadonlySet<string> = new Set<NodeKind>(NODE_KINDS);
  48. const LANGUAGE_VALUES: ReadonlySet<string> = new Set<Language>(LANGUAGES);
  49. /**
  50. * Strip a surrounding pair of double quotes from `s`. Allows users to
  51. * keep whitespace in path filters: `path:"my dir/file"`.
  52. */
  53. function unquote(s: string): string {
  54. if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1);
  55. return s;
  56. }
  57. /**
  58. * Parse a raw query into structured filters + remaining text.
  59. * Always returns a value; never throws.
  60. */
  61. export function parseQuery(raw: string): ParsedQuery {
  62. const out: ParsedQuery = {
  63. text: '',
  64. kinds: [],
  65. languages: [],
  66. pathFilters: [],
  67. nameFilters: [],
  68. };
  69. // Tokenise on whitespace, preserving quoted spans as part of the
  70. // current token. Quotes can appear at the start (`"…"`) OR mid-token
  71. // (`path:"…"`); in both cases everything from the opening `"` to the
  72. // matching `"` is included in the token, whitespace and all.
  73. const tokens: string[] = [];
  74. let i = 0;
  75. while (i < raw.length) {
  76. while (i < raw.length && /\s/.test(raw[i]!)) i++;
  77. if (i >= raw.length) break;
  78. const start = i;
  79. while (i < raw.length && !/\s/.test(raw[i]!)) {
  80. if (raw[i] === '"') {
  81. const end = raw.indexOf('"', i + 1);
  82. if (end === -1) {
  83. // Unterminated quote — swallow the rest of the input as
  84. // one token. Forgiving rather than throwing.
  85. i = raw.length;
  86. break;
  87. }
  88. i = end + 1;
  89. continue;
  90. }
  91. i++;
  92. }
  93. tokens.push(raw.slice(start, i));
  94. }
  95. const textParts: string[] = [];
  96. for (const tok of tokens) {
  97. const colon = tok.indexOf(':');
  98. if (colon <= 0 || colon === tok.length - 1) {
  99. textParts.push(tok);
  100. continue;
  101. }
  102. const key = tok.slice(0, colon).toLowerCase();
  103. const valueRaw = unquote(tok.slice(colon + 1));
  104. if (!valueRaw) {
  105. textParts.push(tok);
  106. continue;
  107. }
  108. switch (key) {
  109. case 'kind': {
  110. if (KIND_VALUES.has(valueRaw)) {
  111. out.kinds.push(valueRaw as NodeKind);
  112. } else {
  113. textParts.push(tok);
  114. }
  115. break;
  116. }
  117. case 'lang':
  118. case 'language': {
  119. const lower = valueRaw.toLowerCase();
  120. if (LANGUAGE_VALUES.has(lower)) {
  121. out.languages.push(lower as Language);
  122. } else {
  123. textParts.push(tok);
  124. }
  125. break;
  126. }
  127. case 'path':
  128. out.pathFilters.push(valueRaw);
  129. break;
  130. case 'name':
  131. out.nameFilters.push(valueRaw);
  132. break;
  133. default:
  134. textParts.push(tok);
  135. }
  136. }
  137. out.text = textParts.join(' ').trim();
  138. return out;
  139. }
  140. /**
  141. * Damerau-Levenshtein-ish bounded edit distance. Returns `maxDist + 1`
  142. * as soon as the distance is known to exceed `maxDist`; that early-exit
  143. * makes the fuzzy fallback cheap even over tens of thousands of names.
  144. *
  145. * Pure DP, O(min(len(a), len(b))) memory. Compares case-folded inputs;
  146. * callers should pass `lowercase(name)` strings.
  147. */
  148. export function boundedEditDistance(a: string, b: string, maxDist: number): number {
  149. if (a === b) return 0;
  150. const al = a.length;
  151. const bl = b.length;
  152. if (Math.abs(al - bl) > maxDist) return maxDist + 1;
  153. if (al === 0) return bl;
  154. if (bl === 0) return al;
  155. let prev = new Array<number>(bl + 1);
  156. let cur = new Array<number>(bl + 1);
  157. for (let j = 0; j <= bl; j++) prev[j] = j;
  158. for (let i = 1; i <= al; i++) {
  159. cur[0] = i;
  160. let rowMin = cur[0]!;
  161. for (let j = 1; j <= bl; j++) {
  162. const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
  163. const insertion = cur[j - 1]! + 1;
  164. const deletion = prev[j]! + 1;
  165. const substitution = prev[j - 1]! + cost;
  166. cur[j] = Math.min(insertion, deletion, substitution);
  167. if (cur[j]! < rowMin) rowMin = cur[j]!;
  168. }
  169. if (rowMin > maxDist) return maxDist + 1;
  170. [prev, cur] = [cur, prev];
  171. }
  172. return prev[bl]!;
  173. }