Skip to main content

aft/
query_shape.rs

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
130/// Tokens for a lexical second chance after an exact or semantic lane misses.
131/// Natural-language and regex shapes normally have no `extract_tokens` output,
132/// but quoted code phrases still carry useful identifiers. Reuse the same quote
133/// parser used for semantic name priors so a query such as `"outside <touser>"
134/// reminder text` can rank files containing either quoted token.
135pub 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
152/// Lexical tokens for a short natural-language concept routed to Hybrid (e.g.
153/// "parse imports"). `extract_tokens` returns nothing for NL (its words are not
154/// code identifiers), but a short two-word concept is frequently a literal code
155/// phrase the trigram lane can match. Split on whitespace and keep words of at
156/// least 3 chars (the trigram floor).
157pub 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
194/// Code-shaped tokens that are explicit enough to use for semantic name priors
195/// inside natural-language queries. Bare prose words are excluded: a lone
196/// capitalized word like "Engine" is too ambiguous unless quoted, while adjacent
197/// TitleCase words are treated as one qualified name such as "Engine.Index".
198pub(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            // Bare escape sequences route to regex via Tier A. Caveat: `foo\n`
1002            // and similar single-backslash-escape after literal text are
1003            // genuinely ambiguous with Windows path segments (e.g., file `n`
1004            // in directory `foo`) and stay on the path/exemption path.
1005            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}