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
137pub 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
156pub fn extract_short_nl_lexical_tokens(query: &str) -> Vec<String> {
158 extract_content_tokens(query)
159}
160
161pub 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
175pub 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
222pub(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 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}