1use regex::Regex;
2use std::sync::LazyLock;
3
4static CAMEL_CASE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[a-z][A-Z]").unwrap());
5static SNAKE_CASE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[a-z]_[a-z]").unwrap());
6static PASCAL_CASE_RE: LazyLock<Regex> =
7 LazyLock::new(|| Regex::new(r"^[A-Z][a-z]+[A-Z]").unwrap());
8static ACRONYM_PASCAL_RE: LazyLock<Regex> =
9 LazyLock::new(|| Regex::new(r"\b[A-Z]{2,}[A-Z][a-z]").unwrap());
10static DOT_PATH_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[a-zA-Z]\.[a-zA-Z]").unwrap());
11static FILE_PATH_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[/\\].*\.\w{1,5}$").unwrap());
12static HEX_CODE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"0x[A-Fa-f0-9]+").unwrap());
13static ERROR_PREFIX_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\bERR_\w+").unwrap());
14static NUMERIC_ERROR_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\bE\d{4,}").unwrap());
15static TYPESCRIPT_ERROR_RE: LazyLock<Regex> =
16 LazyLock::new(|| Regex::new(r"\bTS\d{4,}\b").unwrap());
17static HTTP_STATUS_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b[1-5]\d{2}\b").unwrap());
18static IDENTIFIER_TOKEN_RE: LazyLock<Regex> = LazyLock::new(|| {
19 Regex::new(r"\b[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*\b").unwrap()
20});
21
22static WINDOWS_ABS_PATH_RE: LazyLock<Regex> =
23 LazyLock::new(|| Regex::new(r"^[A-Za-z]:[\\/][A-Za-z0-9_.\-+?\\/' ]+$").unwrap());
24static WINDOWS_REL_PATH_RE: LazyLock<Regex> =
25 LazyLock::new(|| Regex::new(r"^[A-Za-z0-9_.\-+?' ]+(\\[A-Za-z0-9_.\-+?' ]+)+$").unwrap());
26static POSIX_ABS_PATH_RE: LazyLock<Regex> =
27 LazyLock::new(|| Regex::new(r"^/[A-Za-z0-9_.\-+?/' ]+$").unwrap());
28static POSIX_REL_PATH_RE: LazyLock<Regex> =
29 LazyLock::new(|| Regex::new(r"^[A-Za-z0-9_.\-+?' ]+(/[A-Za-z0-9_.\-+?' ]+)+$").unwrap());
30static UNC_PATH_RE: LazyLock<Regex> =
31 LazyLock::new(|| Regex::new(r"^\\\\[A-Za-z0-9_.\-+?\\']+$").unwrap());
32static FILENAME_EXEMPTION_RE: LazyLock<Regex> =
33 LazyLock::new(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_.\-+'? ]*\.[A-Za-z0-9]{1,8}$").unwrap());
34static BRACE_QUANTIFIER_RE: LazyLock<Regex> =
35 LazyLock::new(|| Regex::new(r"\{\d+(?:,\d*)?\}").unwrap());
36static NAMED_CAPTURE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\(\?P<[^>]+>").unwrap());
37static CHAR_RANGE_RE: LazyLock<Regex> =
38 LazyLock::new(|| Regex::new(r"[A-Za-z0-9]-[A-Za-z0-9]").unwrap());
39
40const QUESTION_WORDS: &[&str] = &[
41 "how", "what", "where", "why", "when", "which", "who", "does",
42];
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum QueryKind {
46 Identifier,
47 Mixed,
48 ErrorCode,
49 Path,
50 Regex,
51 NaturalLanguage,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub struct ShapeWeights {
56 pub semantic: f32,
57 pub lexical: f32,
58 pub should_use_lexical: bool,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct QueryShape {
63 pub kind: QueryKind,
64 pub weights: ShapeWeights,
65}
66
67pub fn classify(query: &str) -> QueryShape {
68 let trimmed = query.trim();
69 if trimmed.is_empty() {
70 return shape(QueryKind::NaturalLanguage);
71 }
72
73 if pre_tier_exempt(trimmed).is_some() {
74 return shape(QueryKind::Path);
75 }
76
77 if looks_like_regex(trimmed) {
78 return shape(QueryKind::Regex);
79 }
80
81 let words: Vec<&str> = trimmed.split_whitespace().collect();
82 let word_count = words.len();
83 let first_word_lower = words[0].to_ascii_lowercase();
84
85 if FILE_PATH_RE.is_match(trimmed) {
86 return shape(QueryKind::Path);
87 }
88
89 let has_question_word = QUESTION_WORDS.contains(&first_word_lower.as_str());
90 let is_long_phrase = word_count > 2;
91 let is_two_word_concept = is_two_word_lowercase_concept(&words);
92 let has_natural_language_signals = has_question_word || is_long_phrase || is_two_word_concept;
93 let has_error_code = contains_error_code(trimmed, word_count);
94
95 if has_error_code && has_natural_language_signals {
96 return shape(QueryKind::Mixed);
97 }
98
99 if has_error_code {
100 return shape(QueryKind::ErrorCode);
101 }
102
103 let has_code_identifier = CAMEL_CASE_RE.is_match(trimmed)
104 || SNAKE_CASE_RE.is_match(trimmed)
105 || PASCAL_CASE_RE.is_match(trimmed)
106 || ACRONYM_PASCAL_RE.is_match(trimmed)
107 || DOT_PATH_RE.is_match(trimmed);
108
109 if has_code_identifier && has_natural_language_signals {
110 return shape(QueryKind::Mixed);
111 }
112
113 if has_code_identifier || (word_count <= 2 && !has_natural_language_signals) {
114 return shape(QueryKind::Identifier);
115 }
116
117 shape(QueryKind::NaturalLanguage)
118}
119
120pub fn extract_tokens(query: &str, shape: &QueryShape) -> Vec<String> {
121 match shape.kind {
122 QueryKind::NaturalLanguage | QueryKind::Regex => Vec::new(),
123 QueryKind::Path => extract_path_tokens(query),
124 QueryKind::ErrorCode => extract_error_code_tokens(query),
125 QueryKind::Identifier => extract_identifier_tokens(query, false),
126 QueryKind::Mixed => extract_identifier_tokens(query, true),
127 }
128}
129
130pub fn extract_lexical_tokens(query: &str, shape: &QueryShape) -> Vec<String> {
136 match shape.kind {
137 QueryKind::NaturalLanguage | QueryKind::Regex => {
138 let explicit = extract_explicit_code_tokens(query);
139 if !explicit.is_empty() {
140 return explicit;
141 }
142 if shape.kind == QueryKind::NaturalLanguage {
143 extract_short_nl_lexical_tokens(query)
144 } else {
145 extract_identifier_tokens(query, false)
146 }
147 }
148 _ => extract_tokens(query, shape),
149 }
150}
151
152pub fn extract_short_nl_lexical_tokens(query: &str) -> Vec<String> {
158 query
159 .split_whitespace()
160 .filter(|word| word.chars().count() >= 3)
161 .map(str::to_string)
162 .collect()
163}
164
165pub(crate) fn is_type_concept_identifier_query(query: &str, shape: &QueryShape) -> bool {
166 if shape.kind != QueryKind::Identifier {
167 return false;
168 }
169
170 let mut identifier_token_count = 0;
171 let mut has_type_token = false;
172 let mut has_lowercase_concept_word = false;
173
174 for mat in IDENTIFIER_TOKEN_RE.find_iter(query) {
175 let token = mat.as_str();
176 identifier_token_count += 1;
177 has_type_token |= is_type_concept_type_token(token);
178 has_lowercase_concept_word |= is_dictionary_style_lowercase_word(token);
179 }
180
181 identifier_token_count >= 2 && has_type_token && has_lowercase_concept_word
182}
183
184fn is_type_concept_type_token(token: &str) -> bool {
185 token
186 .chars()
187 .next()
188 .is_some_and(|first| first.is_ascii_uppercase())
189 && (is_titlecase_word(token)
190 || PASCAL_CASE_RE.is_match(token)
191 || ACRONYM_PASCAL_RE.is_match(token))
192}
193
194pub(crate) fn extract_explicit_code_tokens(query: &str) -> Vec<String> {
199 let mut tokens = Vec::new();
200
201 push_quoted_code_tokens(query, &mut tokens);
202 let title_spans = push_adjacent_titlecase_tokens(query, 0, &mut tokens);
203 for mat in IDENTIFIER_TOKEN_RE.find_iter(query) {
204 if span_is_covered(&title_spans, mat.start(), mat.end()) {
205 continue;
206 }
207 let token = mat.as_str();
208 if is_code_identifier_token(token) {
209 push_unique(&mut tokens, token);
210 }
211 }
212
213 tokens
214}
215
216pub fn pre_tier_exempt(query: &str) -> Option<&'static str> {
217 if let Some(kind) = check_url_exemption(query) {
218 return Some(kind);
219 }
220 check_path_exemption(query)
221}
222
223pub fn looks_like_regex(query: &str) -> bool {
224 crate::pattern_compile::detect_unsupported_features(query).is_some()
225 || tier_a_regex_signal(query)
226 || tier_b_character_class(query)
227 || tier_c_adjacent_meta(query)
228}
229
230fn check_url_exemption(query: &str) -> Option<&'static str> {
231 let parsed = url::Url::parse(query).ok()?;
232 if !matches!(parsed.scheme(), "http" | "https" | "file" | "ftp" | "ssh") {
233 return None;
234 }
235 if has_regex_meta_sequences(query) || has_obvious_regex_chars(query) {
236 return None;
237 }
238 Some("url")
239}
240
241fn check_path_exemption(query: &str) -> Option<&'static str> {
242 let kind = if WINDOWS_ABS_PATH_RE.is_match(query) {
243 "windows_abs"
244 } else if WINDOWS_REL_PATH_RE.is_match(query) {
245 "windows_rel"
246 } else if POSIX_ABS_PATH_RE.is_match(query) {
247 "posix_abs"
248 } else if POSIX_REL_PATH_RE.is_match(query) {
249 "posix_rel"
250 } else if UNC_PATH_RE.is_match(query) {
251 "unc"
252 } else if FILENAME_EXEMPTION_RE.is_match(query) {
253 "filename"
254 } else {
255 return None;
256 };
257 if has_path_regex_meta_sequences(query) || has_obvious_regex_chars(query) {
258 return None;
259 }
260 Some(kind)
261}
262
263fn contains_error_code(query: &str, word_count: usize) -> bool {
264 HEX_CODE_RE.is_match(query)
265 || ERROR_PREFIX_RE.is_match(query)
266 || NUMERIC_ERROR_RE.is_match(query)
267 || TYPESCRIPT_ERROR_RE.is_match(query)
268 || has_http_status(query, word_count)
269}
270
271fn has_http_status(query: &str, word_count: usize) -> bool {
272 HTTP_STATUS_RE.is_match(query)
273 && (word_count <= 3 || query.to_ascii_lowercase().contains("http"))
274}
275
276fn is_two_word_lowercase_concept(words: &[&str]) -> bool {
277 words.len() == 2
278 && words
279 .iter()
280 .all(|word| is_dictionary_style_lowercase_word(word))
281}
282
283fn is_dictionary_style_lowercase_word(word: &str) -> bool {
284 word.len() >= 3 && word.bytes().all(|byte| byte.is_ascii_lowercase())
285}
286
287fn has_regex_meta_sequences(query: &str) -> bool {
288 query.contains(".+")
289 || query.contains(".*")
290 || query.contains(".?")
291 || query.contains(r"\n")
292 || query.contains(r"\t")
293 || query.contains(r"\r")
294 || query.contains(r"\b")
295 || query.contains(r"\B")
296 || query.contains(r"\w")
297 || query.contains(r"\W")
298 || query.contains(r"\d")
299 || query.contains(r"\D")
300 || query.contains(r"\s")
301 || query.contains(r"\S")
302 || query.contains(r"\p{")
303 || query.contains(r"\x")
304 || query.contains(r"\u{")
305 || has_escaped_regex_metachar(query)
306}
307
308fn has_path_regex_meta_sequences(query: &str) -> bool {
309 query.contains(".+")
310 || query.contains(".*")
311 || query.contains(".?")
312 || query.contains(r"\p{")
313 || query.contains(r"\x")
314 || query.contains(r"\u{")
315 || has_path_context_regex_escape(query)
316 || has_escaped_regex_metachar(query)
317}
318
319fn has_path_context_regex_escape(query: &str) -> bool {
320 let chars = query.char_indices().collect::<Vec<_>>();
321 for index in 0..chars.len().saturating_sub(1) {
322 if chars[index].1 != '\\' {
323 continue;
324 }
325 let escaped = chars[index + 1].1;
326 if matches!(escaped, 'b' | 'B' | 'w' | 'W' | 'd' | 'D' | 's' | 'S')
327 && path_escape_looks_like_regex(&chars, index + 1)
328 {
329 return true;
330 }
331 }
332 false
333}
334
335fn path_escape_looks_like_regex(chars: &[(usize, char)], escaped_index: usize) -> bool {
336 let Some((_, next)) = chars.get(escaped_index + 1) else {
337 return true;
338 };
339
340 matches!(
341 *next,
342 '*' | '+' | '?' | '{' | '(' | '[' | '|' | '^' | '$' | '\\' | '/'
343 )
344}
345
346fn has_escaped_regex_metachar(query: &str) -> bool {
347 let mut escaped = false;
348 for ch in query.chars() {
349 if escaped {
350 if is_escaped_metachar(ch) {
351 return true;
352 }
353 escaped = false;
354 continue;
355 }
356 escaped = ch == '\\';
357 }
358 false
359}
360
361fn has_obvious_regex_chars(query: &str) -> bool {
362 query.contains('*')
363 || query.contains('[')
364 || query.contains(']')
365 || query.contains('(')
366 || query.contains(')')
367 || query.contains('|')
368 || query.contains('{')
369 || query.contains('}')
370}
371
372fn tier_a_regex_signal(query: &str) -> bool {
373 query.contains("(?:")
374 || NAMED_CAPTURE_RE.is_match(query)
375 || ["(?i)", "(?m)", "(?s)", "(?x)"]
376 .iter()
377 .any(|signal| query.contains(signal))
378 || [
379 r"\b", r"\B", r"\w", r"\W", r"\d", r"\D", r"\s", r"\S", r"\p{", r"\x", r"\u{", r"\n",
380 r"\t", r"\r",
381 ]
382 .iter()
383 .any(|signal| query.contains(signal))
384 || has_brace_quantifier(query)
385 || has_anchored_identifier(query)
386 || has_contextual_escaped_metachar(query)
387}
388
389fn has_brace_quantifier(query: &str) -> bool {
390 for matched in BRACE_QUANTIFIER_RE.find_iter(query) {
391 if matched.start() > 0
392 && query[..matched.start()]
393 .chars()
394 .last()
395 .is_some_and(|ch| !ch.is_whitespace())
396 {
397 return true;
398 }
399 }
400 false
401}
402
403fn has_anchored_identifier(query: &str) -> bool {
404 let trimmed = query.trim();
405 if let Some(rest) = trimmed.strip_prefix('^') {
406 if leading_identifier_len(rest) >= 3 {
407 return true;
408 }
409 }
410 if let Some(rest) = trimmed.strip_suffix('$') {
411 if trailing_identifier_len(rest) >= 3 {
412 return true;
413 }
414 }
415 false
416}
417
418fn leading_identifier_len(text: &str) -> usize {
419 text.chars()
420 .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
421 .count()
422}
423
424fn trailing_identifier_len(text: &str) -> usize {
425 text.chars()
426 .rev()
427 .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
428 .count()
429}
430
431fn has_contextual_escaped_metachar(query: &str) -> bool {
432 let chars: Vec<char> = query.chars().collect();
433 let mut index = 0usize;
434 while index + 1 < chars.len() {
435 if chars[index] == '\\' && is_escaped_metachar(chars[index + 1]) {
436 let literal_after = chars[index + 2..]
437 .iter()
438 .filter(|ch| ch.is_ascii_alphanumeric() || **ch == '_')
439 .count();
440 if literal_after >= 2 {
441 return true;
442 }
443 index += 2;
444 } else {
445 index += 1;
446 }
447 }
448 false
449}
450
451fn is_escaped_metachar(ch: char) -> bool {
452 matches!(
453 ch,
454 '.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$'
455 )
456}
457
458fn tier_b_character_class(query: &str) -> bool {
459 for content in bracket_contents(query) {
460 if content.starts_with('^')
461 || CHAR_RANGE_RE.is_match(&content)
462 || [r"\w", r"\d", r"\s", r"\W", r"\D", r"\S"]
463 .iter()
464 .any(|signal| content.contains(signal))
465 || multi_char_non_identifier_class(&content)
466 {
467 return true;
468 }
469 }
470 false
471}
472
473fn bracket_contents(query: &str) -> Vec<String> {
474 let mut contents = Vec::new();
475 let mut escaped = false;
476 let mut start = None;
477 for (index, ch) in query.char_indices() {
478 if escaped {
479 escaped = false;
480 continue;
481 }
482 if ch == '\\' {
483 escaped = true;
484 continue;
485 }
486 match ch {
487 '[' if start.is_none() => start = Some(index + ch.len_utf8()),
488 ']' => {
489 if let Some(open) = start.take() {
490 contents.push(query[open..index].to_string());
491 }
492 }
493 _ => {}
494 }
495 }
496 contents
497}
498
499fn multi_char_non_identifier_class(content: &str) -> bool {
500 let char_count = content.chars().count();
501 char_count >= 2
502 && !content.chars().any(|ch| {
503 ch.is_ascii_alphanumeric() || ch == '_' || ch == '"' || ch == '\'' || ch == ';'
504 })
505}
506
507fn tier_c_adjacent_meta(query: &str) -> bool {
508 has_dot_quantifier(query)
509 || has_literal_atom_quantifier(query)
510 || has_regex_pipe(query)
511 || escaped_paren_count(query) >= 2
512}
513
514fn has_dot_quantifier(query: &str) -> bool {
515 [".*", ".+", ".?"]
516 .iter()
517 .any(|signal| query.contains(signal) && query.trim().len() > signal.len())
518}
519
520fn has_literal_atom_quantifier(query: &str) -> bool {
521 let chars = query.char_indices().collect::<Vec<_>>();
522 for (index, (byte_index, ch)) in chars.iter().copied().enumerate() {
523 if !is_bare_quantifier(ch) || is_escaped_at(query, byte_index) {
524 continue;
525 }
526 if chars
527 .get(index + 1)
528 .is_some_and(|(_, next)| is_bare_quantifier(*next))
529 {
530 continue;
531 }
532 if ch == '?'
533 && (sentence_final_question_mark_in_phrase(query, byte_index)
534 || question_mark_is_code_shape(&chars, index))
535 {
536 continue;
537 }
538 if previous_is_literal_atom(&chars, index) {
539 return true;
540 }
541 }
542 false
543}
544
545fn sentence_final_question_mark_in_phrase(query: &str, byte_index: usize) -> bool {
546 query[byte_index + '?'.len_utf8()..].trim().is_empty()
547 && query[..byte_index].split_whitespace().count() > 1
548}
549
550fn question_mark_is_code_shape(chars: &[(usize, char)], question_index: usize) -> bool {
551 question_mark_is_optional_chain(chars, question_index)
552 || question_mark_after_empty_call(chars, question_index)
553 || question_mark_after_index_expression(chars, question_index)
554 || question_mark_is_typescript_optional(chars, question_index)
555}
556
557fn question_mark_is_optional_chain(chars: &[(usize, char)], question_index: usize) -> bool {
558 chars
559 .get(question_index + 1)
560 .is_some_and(|(_, next)| *next == '.')
561 && question_index
562 .checked_sub(1)
563 .and_then(|previous_index| chars.get(previous_index))
564 .is_some_and(|(_, previous)| is_code_expression_tail(*previous))
565}
566
567fn question_mark_after_empty_call(chars: &[(usize, char)], question_index: usize) -> bool {
568 let Some(call_open_index) = question_index.checked_sub(2) else {
569 return false;
570 };
571 chars
572 .get(question_index - 1)
573 .is_some_and(|(_, previous)| *previous == ')')
574 && chars
575 .get(call_open_index)
576 .is_some_and(|(_, open)| *open == '(')
577 && call_open_index
578 .checked_sub(1)
579 .and_then(|callee_index| chars.get(callee_index))
580 .is_some_and(|(_, callee_tail)| is_code_expression_tail(*callee_tail))
581}
582
583fn question_mark_after_index_expression(chars: &[(usize, char)], question_index: usize) -> bool {
584 if chars
585 .get(question_index.checked_sub(1).unwrap_or(usize::MAX))
586 .is_none_or(|(_, previous)| *previous != ']')
587 {
588 return false;
589 }
590
591 let mut depth = 0usize;
592 for index in (0..question_index).rev() {
593 match chars[index].1 {
594 ']' => depth += 1,
595 '[' => {
596 depth = depth.saturating_sub(1);
597 if depth == 0 {
598 return index
599 .checked_sub(1)
600 .and_then(|target_index| chars.get(target_index))
601 .is_some_and(|(_, target_tail)| is_code_expression_tail(*target_tail));
602 }
603 }
604 _ => {}
605 }
606 }
607 false
608}
609
610fn question_mark_is_typescript_optional(chars: &[(usize, char)], question_index: usize) -> bool {
611 let previous_is_identifier = question_index
612 .checked_sub(1)
613 .and_then(|previous_index| chars.get(previous_index))
614 .is_some_and(|(_, previous)| is_identifier_tail(*previous));
615 if !previous_is_identifier {
616 return false;
617 }
618 if chars
619 .get(question_index + 1)
620 .is_none_or(|(_, next)| *next != ':')
621 {
622 return false;
623 }
624
625 chars
626 .get(question_index + 2)
627 .is_none_or(|(_, after_colon)| {
628 after_colon.is_whitespace()
629 || after_colon.is_ascii_alphabetic()
630 || matches!(*after_colon, '_' | '{' | '[' | '(' | '"' | '\'')
631 })
632}
633
634fn is_code_expression_tail(ch: char) -> bool {
635 is_identifier_tail(ch) || matches!(ch, ')' | ']')
636}
637
638fn is_identifier_tail(ch: char) -> bool {
639 ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$')
640}
641
642fn previous_is_literal_atom(chars: &[(usize, char)], quantifier_index: usize) -> bool {
643 let Some((_, previous)) = quantifier_index
644 .checked_sub(1)
645 .and_then(|previous_index| chars.get(previous_index))
646 else {
647 return false;
648 };
649
650 previous.is_ascii_alphanumeric() || *previous == '_' || *previous == ')' || *previous == ']'
651}
652
653fn is_bare_quantifier(ch: char) -> bool {
654 matches!(ch, '*' | '+' | '?')
655}
656
657fn is_escaped_at(query: &str, byte_index: usize) -> bool {
658 let backslash_count = query[..byte_index]
659 .chars()
660 .rev()
661 .take_while(|ch| *ch == '\\')
662 .count();
663 backslash_count % 2 == 1
664}
665
666fn has_regex_pipe(query: &str) -> bool {
667 for (index, ch) in query.char_indices() {
668 if ch != '|' {
669 continue;
670 }
671 let left = trailing_identifier_len(&query[..index]);
672 let right = leading_identifier_len(&query[index + ch.len_utf8()..]);
673 if left >= 3 && right >= 3 {
674 return true;
675 }
676 }
677 false
678}
679
680fn escaped_paren_count(query: &str) -> usize {
681 let mut count = 0usize;
682 let mut escaped = false;
683 for ch in query.chars() {
684 if escaped {
685 if ch == '(' || ch == ')' {
686 count += 1;
687 }
688 escaped = false;
689 continue;
690 }
691 escaped = ch == '\\';
692 }
693 count
694}
695
696fn push_quoted_code_tokens(query: &str, tokens: &mut Vec<String>) {
697 let mut open: Option<(char, usize)> = None;
698 let mut escaped = false;
699
700 for (index, ch) in query.char_indices() {
701 if escaped {
702 escaped = false;
703 continue;
704 }
705 if ch == '\\' {
706 escaped = true;
707 continue;
708 }
709
710 if let Some((delimiter, content_start)) = open {
711 if ch == delimiter {
712 let content = &query[content_start..index];
713 let title_spans = push_adjacent_titlecase_tokens(content, content_start, tokens);
714 for mat in IDENTIFIER_TOKEN_RE.find_iter(content) {
715 let start = content_start + mat.start();
716 let end = content_start + mat.end();
717 if !span_is_covered(&title_spans, start, end) {
718 push_unique(tokens, mat.as_str());
719 }
720 }
721 open = None;
722 }
723 } else if matches!(ch, '"' | '\'' | '`') {
724 open = Some((ch, index + ch.len_utf8()));
725 }
726 }
727}
728
729fn push_adjacent_titlecase_tokens(
730 text: &str,
731 base_offset: usize,
732 tokens: &mut Vec<String>,
733) -> Vec<(usize, usize)> {
734 let mut covered_spans = Vec::new();
735 let mut current: Vec<(usize, usize, &str)> = Vec::new();
736 let mut previous_end: Option<usize> = None;
737
738 for mat in IDENTIFIER_TOKEN_RE.find_iter(text) {
739 let token = mat.as_str();
740 let adjacent_to_current = previous_end.is_some_and(|end| {
741 !current.is_empty() && text[end..mat.start()].chars().all(|ch| ch.is_whitespace())
742 });
743 if is_titlecase_word(token) && (current.is_empty() || adjacent_to_current) {
744 current.push((base_offset + mat.start(), base_offset + mat.end(), token));
745 } else {
746 flush_titlecase_sequence(&mut current, &mut covered_spans, tokens);
747 if is_titlecase_word(token) {
748 current.push((base_offset + mat.start(), base_offset + mat.end(), token));
749 }
750 }
751 previous_end = Some(mat.end());
752 }
753
754 flush_titlecase_sequence(&mut current, &mut covered_spans, tokens);
755 covered_spans
756}
757
758fn flush_titlecase_sequence(
759 current: &mut Vec<(usize, usize, &str)>,
760 covered_spans: &mut Vec<(usize, usize)>,
761 tokens: &mut Vec<String>,
762) {
763 if current.len() >= 2 {
764 let qualified = current
765 .iter()
766 .map(|(_, _, token)| *token)
767 .collect::<Vec<_>>()
768 .join(".");
769 push_unique(tokens, &qualified);
770 covered_spans.extend(current.iter().map(|(start, end, _)| (*start, *end)));
771 }
772 current.clear();
773}
774
775fn span_is_covered(spans: &[(usize, usize)], start: usize, end: usize) -> bool {
776 spans
777 .iter()
778 .any(|(span_start, span_end)| start >= *span_start && end <= *span_end)
779}
780
781fn is_titlecase_word(token: &str) -> bool {
782 if token.contains(['.', '_', '$']) {
783 return false;
784 }
785 let mut chars = token.chars();
786 let Some(first) = chars.next() else {
787 return false;
788 };
789 if !first.is_ascii_uppercase() {
790 return false;
791 }
792
793 let mut has_letter_after_first = false;
794 let mut has_lowercase = false;
795 for ch in chars {
796 if !ch.is_ascii_alphanumeric() {
797 return false;
798 }
799 if ch.is_ascii_alphabetic() {
800 has_letter_after_first = true;
801 }
802 if ch.is_ascii_lowercase() {
803 has_lowercase = true;
804 }
805 }
806
807 has_letter_after_first && (has_lowercase || token.chars().all(|ch| !ch.is_ascii_lowercase()))
808}
809
810fn extract_path_tokens(query: &str) -> Vec<String> {
811 let mut tokens = Vec::new();
812 for segment in query
813 .split(['/', '\\'])
814 .filter(|segment| !segment.is_empty())
815 {
816 if segment.contains('.') {
817 if let Some(stem) = segment.rsplit_once('.').map(|(stem, _)| stem) {
818 push_unique(&mut tokens, stem);
819 }
820 }
821 push_unique(&mut tokens, segment);
822 }
823 tokens
824}
825
826fn extract_error_code_tokens(query: &str) -> Vec<String> {
827 let mut tokens = Vec::new();
828 for regex in [
829 &*HEX_CODE_RE,
830 &*ERROR_PREFIX_RE,
831 &*NUMERIC_ERROR_RE,
832 &*TYPESCRIPT_ERROR_RE,
833 &*HTTP_STATUS_RE,
834 ] {
835 for mat in regex.find_iter(query) {
836 push_unique(&mut tokens, mat.as_str());
837 }
838 }
839 if tokens.is_empty() && !query.trim().is_empty() {
840 push_unique(&mut tokens, query.trim());
841 }
842 tokens
843}
844
845fn extract_identifier_tokens(query: &str, require_code_shape: bool) -> Vec<String> {
846 let mut tokens = Vec::new();
847 for mat in IDENTIFIER_TOKEN_RE.find_iter(query) {
848 let token = mat.as_str();
849 if require_code_shape && !is_code_identifier_token(token) {
850 continue;
851 }
852 push_unique(&mut tokens, token);
853 }
854 tokens
855}
856
857fn is_code_identifier_token(token: &str) -> bool {
858 CAMEL_CASE_RE.is_match(token)
859 || SNAKE_CASE_RE.is_match(token)
860 || PASCAL_CASE_RE.is_match(token)
861 || ACRONYM_PASCAL_RE.is_match(token)
862 || DOT_PATH_RE.is_match(token)
863 || ERROR_PREFIX_RE.is_match(token)
864 || NUMERIC_ERROR_RE.is_match(token)
865 || TYPESCRIPT_ERROR_RE.is_match(token)
866}
867
868fn push_unique(tokens: &mut Vec<String>, token: &str) {
869 if !token.is_empty() && !tokens.iter().any(|existing| existing == token) {
870 tokens.push(token.to_string());
871 }
872}
873
874fn shape(kind: QueryKind) -> QueryShape {
875 QueryShape {
876 kind,
877 weights: weights_for(kind),
878 }
879}
880
881fn weights_for(kind: QueryKind) -> ShapeWeights {
882 match kind {
883 QueryKind::Identifier => ShapeWeights {
884 semantic: 0.2,
885 lexical: 0.8,
886 should_use_lexical: true,
887 },
888 QueryKind::Path | QueryKind::ErrorCode => ShapeWeights {
889 semantic: 0.1,
890 lexical: 0.9,
891 should_use_lexical: true,
892 },
893 QueryKind::Regex => ShapeWeights {
894 semantic: 0.0,
895 lexical: 1.0,
896 should_use_lexical: false,
897 },
898 QueryKind::NaturalLanguage => ShapeWeights {
899 semantic: 0.6,
900 lexical: 0.4,
901 should_use_lexical: false,
902 },
903 QueryKind::Mixed => ShapeWeights {
904 semantic: 0.4,
905 lexical: 0.6,
906 should_use_lexical: true,
907 },
908 }
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914
915 fn kind(query: &str) -> QueryKind {
916 classify(query).kind
917 }
918
919 #[test]
920 fn url_exemptions_allow_common_literal_url_punctuation() {
921 for query in [
922 "https://api.io/path",
923 "https://api.io/foo?q=test",
924 "https://api.io/foo+bar",
925 "https://api.io/foo@bar",
926 "https://api.io/foo#anchor",
927 ] {
928 assert_eq!(pre_tier_exempt(query), Some("url"), "{query}");
929 assert_ne!(kind(query), QueryKind::Regex, "{query}");
930 }
931 }
932
933 #[test]
934 fn url_exemptions_reject_regex_sequences() {
935 for query in [
936 "https://.*",
937 "https://api.io/.+",
938 "file://[^ ]+",
939 "file:///tmp/.+",
940 r"https://api.io/users/\w+",
941 ] {
942 assert_eq!(kind(query), QueryKind::Regex, "{query}");
943 }
944 }
945
946 #[test]
947 fn path_and_filename_exemptions_allow_literal_punctuation() {
948 for (query, expected) in [
949 (r"C:\new\test", "windows_abs"),
950 (r"src\bin\main.rs", "windows_rel"),
951 (r"src\tab\main.ts", "windows_rel"),
952 (r"packages\opencode-plugin\src", "windows_rel"),
953 ("/usr/local/bin", "posix_abs"),
954 ("/Users/John Doe/Documents", "posix_abs"),
955 ("/home/user/.gitignore", "posix_abs"),
956 ("v1/release/notes.md", "posix_rel"),
957 ("/home/user/jeff's-folder", "posix_abs"),
958 ("C++/parser/main.cpp", "posix_rel"),
959 ("foo+bar/baz.ts", "posix_rel"),
960 ("is_valid?.ts", "filename"),
961 ("Cargo.lock", "filename"),
962 ("tsconfig.json", "filename"),
963 ] {
964 assert_eq!(pre_tier_exempt(query), Some(expected), "{query}");
965 assert_eq!(kind(query), QueryKind::Path, "{query}");
966 }
967 assert_eq!(pre_tier_exempt("foo?"), None);
968 }
969
970 #[test]
971 fn path_exemptions_reject_regex_sequences() {
972 for query in [
973 "src/.*",
974 "src/.+",
975 r"C:\bin\foo*.exe",
976 r"C:\Users\\w+",
977 r"src\w+\main.ts",
978 ] {
979 assert_eq!(kind(query), QueryKind::Regex, "{query}");
980 }
981 }
982
983 #[test]
984 fn tier_a_and_c_regex_signals_route_to_regex() {
985 for query in [
986 "^export",
987 "foo$",
988 "^main$",
989 r"foo\.bar",
990 r"\(method\)",
991 r"\bTODO\b",
992 ".*foo",
993 "foo|bar",
994 "(?:foo)",
995 "(?P<n>foo)",
996 "(?i)Todo",
997 r"\p{Lu}",
998 r"\xFF",
999 r"\u{1F600}",
1000 "a{3}",
1001 r"\n",
1006 r"\t",
1007 r"\r",
1008 r"\tindent",
1009 ] {
1010 assert_eq!(kind(query), QueryKind::Regex, "{query}");
1011 }
1012 }
1013
1014 #[test]
1015 fn character_classes_route_only_when_they_look_like_classes() {
1016 for query in ["[a-z]+", "[^abc]", r"[\w]+"] {
1017 assert_eq!(kind(query), QueryKind::Regex, "{query}");
1018 }
1019 for query in [
1020 "arr[0]",
1021 "obj[key]",
1022 "config[\"key\"]",
1023 "#[derive]",
1024 "Vec<[u8; 32]>",
1025 ] {
1026 assert_ne!(kind(query), QueryKind::Regex, "{query}");
1027 }
1028 }
1029
1030 #[test]
1031 fn unsupported_regex_syntax_still_routes_to_regex_for_compile_error() {
1032 for query in [
1033 "(?=foo)",
1034 "(?!foo)",
1035 "(?<=foo)",
1036 "(?<!foo)",
1037 "(?P=name)",
1038 r"\1",
1039 "foo*+",
1040 "(?>foo)",
1041 ] {
1042 assert_eq!(kind(query), QueryKind::Regex, "{query}");
1043 }
1044 }
1045
1046 #[test]
1047 fn explicit_code_tokens_for_natural_language_skip_bare_capitalized_words() {
1048 assert!(extract_explicit_code_tokens("Engine implementations").is_empty());
1049 assert_eq!(
1050 extract_explicit_code_tokens("find `Engine` and engine_factory"),
1051 vec!["Engine".to_string(), "engine_factory".to_string()]
1052 );
1053 assert_eq!(
1054 extract_explicit_code_tokens("Engine Index"),
1055 vec!["Engine.Index".to_string()]
1056 );
1057 assert_eq!(
1058 extract_explicit_code_tokens("use Engine.Index and AllocationService"),
1059 vec!["Engine.Index".to_string(), "AllocationService".to_string()]
1060 );
1061 }
1062
1063 #[test]
1064 fn two_word_lowercase_concepts_route_to_natural_language() {
1065 for query in ["retry logic", "auth flow", "cache invalidation"] {
1066 assert_eq!(kind(query), QueryKind::NaturalLanguage, "{query}");
1067 }
1068 }
1069
1070 #[test]
1071 fn identifierish_short_queries_stay_identifier() {
1072 for query in ["useState hook", "parseConfig", "parse_config option"] {
1073 assert_eq!(kind(query), QueryKind::Identifier, "{query}");
1074 }
1075 }
1076
1077 #[test]
1078 fn question_mark_code_shapes_do_not_route_to_regex() {
1079 for query in ["foo()?", "optional?.length", "user?.name", "arr[0]?"] {
1080 assert_ne!(kind(query), QueryKind::Regex, "{query}");
1081 }
1082 }
1083
1084 #[test]
1085 fn question_mark_regex_quantifiers_still_route_to_regex() {
1086 for query in ["colou?r", "https?"] {
1087 assert_eq!(kind(query), QueryKind::Regex, "{query}");
1088 }
1089 }
1090
1091 #[test]
1092 fn weak_regex_like_punctuation_does_not_route_to_regex() {
1093 for query in [
1094 "^id",
1095 "id$",
1096 "^",
1097 "$",
1098 "$HOME",
1099 r"\.",
1100 "array.length",
1101 "foo()",
1102 "map.get(key)",
1103 "a|b",
1104 ] {
1105 assert_ne!(kind(query), QueryKind::Regex, "{query}");
1106 }
1107 }
1108}