(
query: string,
indexedPaths: readonly string[],
opts: { maxPins?: number; maxMatchesPerSpan?: number } = {},
)
| 187 | } |
| 188 | |
| 189 | export function extractQueryPaths( |
| 190 | query: string, |
| 191 | indexedPaths: readonly string[], |
| 192 | opts: { maxPins?: number; maxMatchesPerSpan?: number } = {}, |
| 193 | ): QueryPathExtraction { |
| 194 | const maxPins = Math.max(1, opts.maxPins ?? 8); |
| 195 | const maxMatchesPerSpan = Math.max(1, opts.maxMatchesPerSpan ?? 3); |
| 196 | |
| 197 | const passthrough: QueryPathExtraction = { |
| 198 | strippedQuery: query, |
| 199 | pinnedFiles: [], |
| 200 | unresolvedPathSpans: [], |
| 201 | }; |
| 202 | if (!query.trim() || indexedPaths.length === 0) return passthrough; |
| 203 | |
| 204 | // Lowercase view of the index, built once per call. Last writer wins on a |
| 205 | // case-colliding pair, which is the existing file-view behavior too. |
| 206 | const lowerToOriginal = new Map<string, string>(); |
| 207 | for (const p of indexedPaths) lowerToOriginal.set(p.toLowerCase(), p); |
| 208 | |
| 209 | const tokens = query.split(/\s+/).filter(Boolean); |
| 210 | const consumed = new Set<number>(); |
| 211 | const pinned: string[] = []; |
| 212 | const pinnedSeen = new Set<string>(); |
| 213 | const unresolved: string[] = []; |
| 214 | let candidatesExamined = 0; |
| 215 | |
| 216 | for (let i = 0; i < tokens.length; i++) { |
| 217 | if (pinned.length >= maxPins) break; |
| 218 | if (candidatesExamined >= MAX_CANDIDATE_SPANS) break; |
| 219 | const stripped = stripWrapping(tokens[i]!); |
| 220 | if (stripped.length < 4) continue; |
| 221 | const hasSlash = /[/\\]/.test(stripped); |
| 222 | if (!hasSlash && !DOTTED_BASENAME.test(stripped)) continue; |
| 223 | |
| 224 | const normalized = normalizeSpan(stripped); |
| 225 | if (!normalized) continue; |
| 226 | candidatesExamined++; |
| 227 | |
| 228 | const { matches, ambiguous } = resolveSpan( |
| 229 | normalized.toLowerCase(), lowerToOriginal, maxMatchesPerSpan, |
| 230 | ); |
| 231 | if (matches.length > 0) { |
| 232 | consumed.add(i); |
| 233 | for (const m of matches) { |
| 234 | if (pinnedSeen.has(m) || pinned.length >= maxPins) continue; |
| 235 | pinnedSeen.add(m); |
| 236 | pinned.push(m); |
| 237 | } |
| 238 | } else if (ambiguous || isClearlyPathShaped(normalized)) { |
| 239 | // A real path that didn't resolve to a usable set. Keeping it in the |
| 240 | // query is strictly worse — its fragments are what minted the junk |
| 241 | // matches this module exists to stop — so strip it and say so. |
| 242 | consumed.add(i); |
| 243 | if (unresolved.length < 4) unresolved.push(normalized); |
| 244 | } |
| 245 | // Anything else (`and/or`, `call/2`, `foo.Bar`) is not a path reference: |
| 246 | // leave the token for the normal matching pipeline. |
no test coverage detected