(query: string, options?: { stems?: boolean })
| 154 | * so FTS prefix matching can find related code symbols. |
| 155 | */ |
| 156 | export function extractSearchTerms(query: string, options?: { stems?: boolean }): string[] { |
| 157 | const includeStems = options?.stems !== false; |
| 158 | const tokens = new Set<string>(); |
| 159 | |
| 160 | // First, extract and preserve compound identifiers before splitting |
| 161 | // CamelCase: scrapeLoop, UserService, getCallGraph |
| 162 | const compoundPattern = /\b([a-zA-Z][a-zA-Z0-9]*(?:[A-Z][a-z]+)+|[A-Z][a-z]+(?:[A-Z][a-z]*)+)\b/g; |
| 163 | let match; |
| 164 | while ((match = compoundPattern.exec(query)) !== null) { |
| 165 | if (match[1] && match[1].length >= 3) { |
| 166 | tokens.add(match[1].toLowerCase()); // preserve full compound: "scrapeloop" |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // snake_case: scrape_loop, user_service |
| 171 | const snakePattern = /\b([a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+)\b/g; |
| 172 | while ((match = snakePattern.exec(query)) !== null) { |
| 173 | if (match[1] && match[1].length >= 3) { |
| 174 | tokens.add(match[1].toLowerCase()); |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | // Split camelCase / PascalCase: "getUserName" → "get User Name" |
| 179 | const camelSplit = query |
| 180 | .replace(/([a-z])([A-Z])/g, '$1 $2') |
| 181 | .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2'); |
| 182 | |
| 183 | // Replace underscores and dots with spaces (snake_case, dot.notation) |
| 184 | const normalised = camelSplit.replace(/[_.]+/g, ' '); |
| 185 | |
| 186 | // Split on any non-alphanumeric character |
| 187 | const words = normalised.split(/[^a-zA-Z0-9]+/).filter(Boolean); |
| 188 | |
| 189 | for (const word of words) { |
| 190 | const lower = word.toLowerCase(); |
| 191 | if (lower.length < 3) continue; |
| 192 | if (STOP_WORDS.has(lower)) continue; |
| 193 | tokens.add(lower); |
| 194 | } |
| 195 | |
| 196 | // Generate stem variants for broader FTS matching. |
| 197 | // "caching" → "cache" finds CacheBuilder; "eviction" → "evict" finds evictEntries. |
| 198 | // Also enables co-occurrence dampening by increasing term count above 1. |
| 199 | // Stems are skipped when scoring path relevance (stems inflate path scores). |
| 200 | if (includeStems) { |
| 201 | const stems = new Set<string>(); |
| 202 | for (const token of tokens) { |
| 203 | for (const variant of getStemVariants(token)) { |
| 204 | if (!tokens.has(variant) && !STOP_WORDS.has(variant)) { |
| 205 | stems.add(variant); |
| 206 | } |
| 207 | } |
| 208 | } |
| 209 | for (const stem of stems) { |
| 210 | tokens.add(stem); |
| 211 | } |
| 212 | } |
| 213 |
no test coverage detected