Skip to main content

aft/
query_shape.rs

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