1use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct PackItem {
23 pub path: String,
24 pub lang: String,
25 pub name: String,
26 pub kind: String,
27 pub line_start: u32,
28 pub line_end: u32,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub signature: Option<String>,
31 pub snippet_start: u32,
33 pub code: String,
35 pub reason: String,
37 pub score: f32,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ContextPack {
43 pub task: String,
44 pub budget_tokens: u64,
45 pub used_tokens: u64,
46 pub truncated: bool,
48 pub items: Vec<PackItem>,
49}
50
51pub const CHARS_PER_TOKEN: u64 = 4;
53
54pub fn est_tokens(chars: u64) -> u64 {
56 chars / CHARS_PER_TOKEN
57}
58
59pub fn tokenize(task: &str) -> Vec<String> {
63 let mut terms: Vec<String> = Vec::new();
64 let mut seen = std::collections::HashSet::new();
65 for raw in task.split(|c: char| !(c.is_alphanumeric() || c == '_')) {
66 if raw.is_empty() {
67 continue;
68 }
69 for tok in split_identifier(raw) {
70 if tok.len() < 2 || is_stopword(&tok) {
71 continue;
72 }
73 if seen.insert(tok.clone()) {
74 terms.push(tok);
75 }
76 }
77 }
78 terms
79}
80
81fn is_stopword(t: &str) -> bool {
82 matches!(
83 t,
84 "the"
85 | "a"
86 | "an"
87 | "of"
88 | "to"
89 | "in"
90 | "is"
91 | "for"
92 | "and"
93 | "or"
94 | "how"
95 | "where"
96 | "what"
97 | "does"
98 | "do"
99 | "with"
100 | "on"
101 | "by"
102 | "this"
103 | "that"
104 | "it"
105 | "be"
106 | "as"
107 | "at"
108 | "we"
109 | "i"
110 | "add"
111 | "fix"
112 | "use"
113 | "using"
114 | "make"
115 | "get"
116 | "set"
117 | "all"
118 | "when"
119 | "from"
120 | "into"
121 | "via"
122 | "can"
123 | "should"
124 | "code"
125 | "function"
126 | "method"
127 )
128}
129
130pub fn lexical_score(
137 name: &str,
138 kind: &str,
139 signature: Option<&str>,
140 container: Option<&str>,
141 path: &str,
142 terms: &[String],
143) -> f32 {
144 if terms.is_empty() {
145 return 0.0;
146 }
147 let mut scratch = ScoreScratch::default();
148 let path_bonus = path_term_bonus(path, terms, &mut scratch);
149 lexical_score_with(
150 name,
151 kind,
152 signature,
153 container,
154 path_bonus,
155 terms,
156 &mut scratch,
157 )
158}
159
160#[derive(Default)]
173pub struct ScoreScratch {
174 path_lower: String,
175 name_lower: String,
176 sig_lower: String,
177 name_tokens: Vec<String>,
178 cont_tokens: Vec<String>,
179}
180
181fn ascii_lower_into(s: &str, buf: &mut String) {
183 buf.clear();
184 buf.push_str(s);
185 buf.make_ascii_lowercase();
186}
187
188pub fn path_term_bonus(path: &str, terms: &[String], scratch: &mut ScoreScratch) -> f32 {
192 ascii_lower_into(path, &mut scratch.path_lower);
193 let mut bonus = 0.0f32;
194 for term in terms {
195 if scratch.path_lower.contains(term.as_str()) {
196 bonus += 2.0;
197 }
198 }
199 bonus
200}
201
202pub fn lexical_score_with(
205 name: &str,
206 kind: &str,
207 signature: Option<&str>,
208 container: Option<&str>,
209 path_bonus: f32,
210 terms: &[String],
211 scratch: &mut ScoreScratch,
212) -> f32 {
213 if terms.is_empty() {
214 return 0.0;
215 }
216 ascii_lower_into(name, &mut scratch.name_lower);
217 let n_name = split_identifier_into(name, &mut scratch.name_tokens);
218 match signature {
219 Some(s) => ascii_lower_into(s, &mut scratch.sig_lower),
220 None => scratch.sig_lower.clear(),
221 }
222 let n_cont = match container {
223 Some(c) => split_identifier_into(c, &mut scratch.cont_tokens),
224 None => 0,
225 };
226
227 let name_lower = &scratch.name_lower;
228 let mut score = path_bonus;
229 for term in terms {
230 if name_lower == term {
231 score += 20.0;
232 } else if scratch.name_tokens[..n_name].iter().any(|t| t == term) {
233 score += 12.0;
234 } else if name_lower.contains(term.as_str()) {
235 score += 6.0;
236 }
237 if scratch.cont_tokens[..n_cont].iter().any(|t| t == term) {
238 score += 4.0;
239 }
240 if signature.is_some() && scratch.sig_lower.contains(term.as_str()) {
241 score += 3.0;
242 }
243 }
244 if score > 0.0 && is_priority_kind(kind) {
245 score += 2.0;
246 }
247 score
248}
249
250fn is_priority_kind(kind: &str) -> bool {
251 matches!(
252 kind,
253 "function"
254 | "method"
255 | "struct"
256 | "class"
257 | "trait"
258 | "interface"
259 | "enum"
260 | "type"
261 | "constructor"
262 | "module"
263 )
264}
265
266pub fn split_identifier(s: &str) -> Vec<String> {
268 let mut tokens = Vec::new();
269 let n = split_identifier_into(s, &mut tokens);
270 tokens.truncate(n);
271 tokens
272}
273
274fn split_identifier_into(s: &str, out: &mut Vec<String>) -> usize {
281 let mut n = 0usize;
282 let mut prev_lower = false;
283 let mut open = false;
287 for ch in s.chars() {
288 if ch == '_' || ch == '-' || ch == ' ' {
289 if open {
290 n += 1;
291 open = false;
292 }
293 prev_lower = false;
294 continue;
295 }
296 if ch.is_uppercase() && prev_lower && open {
297 n += 1;
298 open = false;
299 }
300 if !open {
301 if out.len() == n {
302 out.push(String::new());
303 } else {
304 out[n].clear();
305 }
306 open = true;
307 }
308 out[n].extend(ch.to_lowercase());
309 prev_lower = ch.is_lowercase() || ch.is_numeric();
310 }
311 if open {
312 n += 1;
313 }
314 n
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn tokenizes_and_drops_stopwords() {
323 let t = tokenize("How does the SegmentWriter flush to disk?");
324 assert!(t.contains(&"segment".to_string()));
325 assert!(t.contains(&"writer".to_string()));
326 assert!(t.contains(&"flush".to_string()));
327 assert!(t.contains(&"disk".to_string()));
328 assert!(!t.contains(&"the".to_string()));
329 assert!(!t.contains(&"how".to_string()));
330 }
331
332 #[test]
333 fn scores_name_matches_highest() {
334 let terms = tokenize("flush segment writer");
335 let exact = lexical_score("flush", "function", None, None, "src/a.rs", &terms);
336 let unrelated = lexical_score("zebra", "function", None, None, "src/a.rs", &terms);
337 assert!(exact > unrelated);
338 assert_eq!(unrelated, 0.0);
339 }
340
341 const IDENTS: &[&str] = &[
345 "",
346 "x",
347 "flush",
348 "loadConfig",
349 "load_config",
350 "kebab-case-name",
351 "with space",
352 "__leading",
353 "trailing__",
354 "a__b",
355 "HTTPServer",
356 "parseHTTP2Frame",
357 "v2Handler",
358 "snake_And_Camel",
359 "ÄÖÜ_grüß",
360 "İstanbul",
361 "ALLCAPS",
362 ];
363
364 #[test]
368 fn split_into_matches_allocating_version_and_reuses_buffer() {
369 let mut buf: Vec<String> = Vec::new();
370 for s in IDENTS {
371 let want = split_identifier(s);
372 let n = split_identifier_into(s, &mut buf);
373 assert_eq!(n, want.len(), "token count for {s:?}");
374 assert_eq!(&buf[..n], &want[..], "tokens for {s:?}");
375 }
376 for s in IDENTS.iter().rev() {
379 let want = split_identifier(s);
380 let n = split_identifier_into(s, &mut buf);
381 assert_eq!(&buf[..n], &want[..], "tokens for {s:?} after reuse");
382 }
383 }
384
385 #[test]
388 fn scratch_reuse_does_not_change_scores() {
389 let terms = tokenize("write back page cache to disk");
390 type Case<'a> = (&'a str, &'a str, Option<&'a str>, Option<&'a str>, &'a str);
392 let cases: &[Case] = &[
393 ("writeback", "function", None, None, "mm/page-writeback.c"),
394 (
395 "write_back_pages",
396 "function",
397 Some("int write_back_pages(struct page *p)"),
398 Some("PageCache"),
399 "fs/read_write.c",
400 ),
401 ("zebra", "struct", None, None, "drivers/zoo.c"),
402 (
403 "cache",
404 "method",
405 Some("void cache(void)"),
406 None,
407 "mm/cache.c",
408 ),
409 (
410 "İstanbul",
411 "function",
412 None,
413 Some("ÄÖÜ_grüß"),
414 "i18n/ünicode.c",
415 ),
416 ("x", "field", Some("disk"), Some("page"), "a.c"),
417 ("PageWriteback", "class", None, None, "include/linux/page.h"),
418 ];
419 let mut shared = ScoreScratch::default();
420 for (name, kind, sig, cont, path) in cases {
421 let mut fresh = ScoreScratch::default();
423 let want = lexical_score_with(
424 name,
425 kind,
426 *sig,
427 *cont,
428 path_term_bonus(path, &terms, &mut fresh),
429 &terms,
430 &mut fresh,
431 );
432 let got = lexical_score_with(
433 name,
434 kind,
435 *sig,
436 *cont,
437 path_term_bonus(path, &terms, &mut shared),
438 &terms,
439 &mut shared,
440 );
441 assert_eq!(got, want, "score for {name:?} in {path:?}");
442 assert_eq!(
444 lexical_score(name, kind, *sig, *cont, path, &terms),
445 want,
446 "wrapper score for {name:?}"
447 );
448 }
449 }
450
451 #[test]
456 fn empty_and_none_signature_container_are_distinguished() {
457 let terms = tokenize("alpha beta");
458 let mut sh = ScoreScratch::default();
459 let _ = lexical_score_with(
461 "alpha_beta_gamma",
462 "function",
463 Some("fn alpha(beta: Beta) -> Gamma"),
464 Some("AlphaContainer"),
465 0.0,
466 &terms,
467 &mut sh,
468 );
469 let cases: &[(Option<&str>, Option<&str>)] = &[
470 (None, None),
471 (Some(""), None),
472 (None, Some("")),
473 (Some(""), Some("")),
474 (Some("alpha"), Some("beta")),
475 ];
476 for (sig, cont) in cases {
477 let mut fresh = ScoreScratch::default();
478 let want = lexical_score_with("zzz", "other", *sig, *cont, 0.0, &terms, &mut fresh);
479 let got = lexical_score_with("zzz", "other", *sig, *cont, 0.0, &terms, &mut sh);
480 assert_eq!(got, want, "sig={sig:?} cont={cont:?}");
481 assert_eq!(
482 lexical_score("zzz", "other", *sig, *cont, "x.rs", &terms),
483 want,
484 "wrapper disagrees for sig={sig:?} cont={cont:?}"
485 );
486 }
487 }
488
489 #[test]
492 fn path_bonus_counts_each_matching_term_once() {
493 let terms = tokenize("page cache writeback");
494 let mut s = ScoreScratch::default();
495 assert_eq!(path_term_bonus("mm/nothing.c", &terms, &mut s), 0.0);
496 assert_eq!(path_term_bonus("mm/PAGE.c", &terms, &mut s), 2.0);
497 assert_eq!(path_term_bonus("mm/page-writeback.c", &terms, &mut s), 4.0);
498 assert!(lexical_score("zebra", "other", None, None, "mm/page.c", &terms) > 0.0);
501 }
502}