1use std::sync::Arc;
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use rustc_hash::{FxHashMap, FxHashSet};
6
7use crate::config::needs::NEEDS;
8use crate::stopwords::CODE_STOPWORDS;
9use crate::types::{Fragment, FragmentId, extract_identifiers};
10
11static CALL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(\w+)\s*\(").unwrap());
12static TYPE_REF_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?::|->)\s*([A-Z]\w+)").unwrap());
13static GENERIC_TYPE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[\[<,]\s*([A-Z]\w*)").unwrap());
14static INVARIANT_RE: Lazy<Regex> = Lazy::new(|| {
15 Regex::new(r"(?i)\b(?:assert|require|ensure|precondition|postcondition|invariant)\s*\(\s*(\w+)")
16 .unwrap()
17});
18static JS_IMPORT_RE: Lazy<Regex> =
19 Lazy::new(|| Regex::new(r#"import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]"#).unwrap());
20static PY_IMPORT_RE: Lazy<Regex> =
21 Lazy::new(|| Regex::new(r"from\s+(\S+)\s+import\s+(.+)").unwrap());
22static JS_LOCAL_IMPORT_RE: Lazy<Regex> = Lazy::new(|| {
23 Regex::new(r#"import\s+(?:\{([^}]+)\}|([A-Z]\w+))\s+from\s+['"]([^'"]+)['"]"#).unwrap()
24});
25static TF_VAR_NEED_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"var\.(\w+)").unwrap());
26static TF_RES_REF_NEED_RE: Lazy<Regex> = Lazy::new(|| {
27 Regex::new(r"(?:^|[^.\w])([a-zA-Z]\w*)\.(\w+)(?:\[\*?\w*\])?\.[\w\[\]*]+").unwrap()
28});
29
30static LANGUAGE_BUILTINS: Lazy<FxHashSet<String>> = Lazy::new(|| {
31 [
32 "range",
33 "enumerate",
34 "zip",
35 "sorted",
36 "reversed",
37 "isinstance",
38 "issubclass",
39 "hasattr",
40 "getattr",
41 "setattr",
42 "delattr",
43 "callable",
44 "iter",
45 "next",
46 "any",
47 "all",
48 "abs",
49 "round",
50 "pow",
51 "divmod",
52 "repr",
53 "dir",
54 "vars",
55 "globals",
56 "locals",
57 "breakpoint",
58 "property",
59 "classmethod",
60 "staticmethod",
61 "dataclass",
62 "object",
63 "exception",
64 "baseexception",
65 "valueerror",
66 "typeerror",
67 "keyerror",
68 "indexerror",
69 "attributeerror",
70 "importerror",
71 "runtimeerror",
72 "stopiteration",
73 "generatorexit",
74 "oserror",
75 "ioerror",
76 "filenotfounderror",
77 "permissionerror",
78 "notimplementederror",
79 "zerodivisionerror",
80 "overflowerror",
81 "memoryerror",
82 "recursionerror",
83 "unicodeerror",
84 "assertionerror",
85 "lookuperror",
86 "arithmeticerror",
87 "array.from",
88 "object.keys",
89 "object.values",
90 "object.entries",
91 "array.isarray",
92 "number.isnan",
93 "number.isfinite",
94 "parseint",
95 "parsefloat",
96 "isnan",
97 "isfinite",
98 "settimeout",
99 "setinterval",
100 "clearinterval",
101 "cleartimeout",
102 "requestanimationframe",
103 "cancelanimationframe",
104 "typeof",
105 "void",
106 "make",
107 "append",
108 "panic",
109 "recover",
110 "cap",
111 "println",
112 "printf",
113 "sprintf",
114 "fprintf",
115 "errorf",
116 "vec",
117 "arc",
118 "unwrap",
119 "usestate",
120 "useeffect",
121 "usecontext",
122 "usereducer",
123 "usecallback",
124 "usememo",
125 "useref",
126 "uselayouteffect",
127 "useimperativehandle",
128 "usedebugvalue",
129 "useid",
130 "usetransition",
131 "usedeferredvalue",
132 "createcontext",
133 "forwardref",
134 "createref",
135 "suspense",
136 "strictmode",
137 "profiler",
138 "usenavigate",
139 "useparams",
140 "uselocation",
141 "usesearchparams",
142 "useloaderdata",
143 "useactiondata",
144 "usefetcher",
145 "useoutletcontext",
146 "usedispatch",
147 "useselector",
148 "usestore",
149 "usequery",
150 "usemutation",
151 "usesubscription",
152 "describe",
153 "beforeeach",
154 "aftereach",
155 "beforeall",
156 "afterall",
157 "assert",
158 ]
159 .iter()
160 .map(|s| s.to_string())
161 .collect()
162});
163
164static COMMENT_PREFIXES: &[&str] = &["#", "//", "* ", "/*", "--", "\"\"\"", "'''", "<!--"];
165static ONE_CLASS_PER_FILE_SUFFIXES: &[&str] = &[".swift", ".java", ".kt"];
166static TF_EXTENSIONS: &[&str] = &[".tf", ".tfvars", ".hcl"];
167static CONFIG_EXTENSIONS_FOR_DIFF: &[&str] = &[".yaml", ".yml", ".json", ".toml", ".ini"];
168static TF_SKIP_REF_TYPES: &[&str] = &[
169 "var",
170 "local",
171 "data",
172 "module",
173 "path",
174 "terraform",
175 "count",
176 "each",
177 "self",
178];
179
180#[derive(Debug, Clone)]
181pub struct InformationNeed {
182 pub need_type: String,
183 pub symbol: String,
184 pub scope: Option<Arc<str>>,
185 pub priority: f64,
186}
187
188fn parse_import_names(names_str: &str) -> FxHashSet<String> {
189 let mut result = FxHashSet::default();
190 for name in names_str.split(',') {
191 let name = name.trim().split(" as ").next().unwrap_or("").trim();
192 if !name.is_empty() {
193 result.insert(name.to_lowercase());
194 }
195 }
196 result
197}
198
199fn collect_external_symbols_from_lines(changed_lines: &[&str]) -> FxHashSet<String> {
200 let mut symbols = FxHashSet::default();
201 for line in changed_lines {
202 for m in JS_IMPORT_RE.captures_iter(line) {
203 let js_names = &m[1];
204 let js_source = &m[2];
205 if !js_source.starts_with('.') {
206 symbols.extend(parse_import_names(js_names));
207 }
208 }
209 for m in PY_IMPORT_RE.captures_iter(line) {
210 let py_module = &m[1];
211 let py_names = &m[2];
212 if !py_module.starts_with('.') {
213 symbols.extend(parse_import_names(py_names));
214 }
215 }
216 }
217 symbols
218}
219
220fn is_local_import(source: &str) -> bool {
221 source.starts_with('.') || source.starts_with("@/") || source.starts_with("~/")
222}
223
224fn add_needs_for_syms(
225 syms: &FxHashSet<String>,
226 needs: &mut FxHashMap<(String, String), InformationNeed>,
227) {
228 for sym in syms {
229 if sym.len() >= NEEDS.min_symbol_length && !CODE_STOPWORDS.contains(sym) {
230 let key = ("definition".to_string(), sym.clone());
231 needs.entry(key).or_insert_with(|| InformationNeed {
232 need_type: "definition".to_string(),
233 symbol: sym.clone(),
234 scope: None,
235 priority: NEEDS.definition_priority,
236 });
237 }
238 }
239}
240
241fn collect_js_import_needs(line: &str, needs: &mut FxHashMap<(String, String), InformationNeed>) {
242 for m in JS_LOCAL_IMPORT_RE.captures_iter(line) {
243 let named = m.get(1).map(|x| x.as_str());
244 let default = m.get(2).map(|x| x.as_str());
245 let source = &m[3];
246 if !is_local_import(source) {
247 continue;
248 }
249 let mut syms = FxHashSet::default();
250 if let Some(named) = named {
251 syms = parse_import_names(named);
252 } else if let Some(default) = default {
253 syms.insert(default.to_lowercase());
254 }
255 add_needs_for_syms(&syms, needs);
256 }
257}
258
259fn collect_py_import_needs(line: &str, needs: &mut FxHashMap<(String, String), InformationNeed>) {
260 for m in PY_IMPORT_RE.captures_iter(line) {
261 let module = &m[1];
262 let names = &m[2];
263 if !module.starts_with('.') {
264 continue;
265 }
266 let syms = parse_import_names(names);
267 add_needs_for_syms(&syms, needs);
268 }
269}
270
271fn collect_import_needs(
272 changed_lines: &[&str],
273 needs: &mut FxHashMap<(String, String), InformationNeed>,
274) {
275 for line in changed_lines {
276 collect_js_import_needs(line, needs);
277 collect_py_import_needs(line, needs);
278 }
279}
280
281fn is_comment_line(line: &str) -> bool {
282 let stripped = line.trim_start();
283 COMMENT_PREFIXES.iter().any(|p| stripped.starts_with(p))
284}
285
286fn defines_strength(scope_match: bool, has_scope: bool) -> f64 {
287 if scope_match {
288 NEEDS.defines_scope_match
289 } else if !has_scope {
290 NEEDS.defines_no_scope
291 } else {
292 NEEDS.defines_other_scope
293 }
294}
295
296fn is_test_file(path: &str) -> bool {
297 let lower = path.to_lowercase();
298 let name = std::path::Path::new(&lower)
299 .file_name()
300 .map(|n| n.to_string_lossy().to_string())
301 .unwrap_or_default();
302
303 name.starts_with("test_")
304 || name.ends_with("_test.py")
305 || name.ends_with("_test.go")
306 || name.ends_with(".test.ts")
307 || name.ends_with(".test.tsx")
308 || name.ends_with(".test.js")
309 || name.ends_with(".test.jsx")
310 || name.ends_with(".spec.ts")
311 || name.ends_with(".spec.tsx")
312 || name.ends_with(".spec.js")
313 || name.ends_with(".spec.jsx")
314 || name.ends_with("test.java")
315 || name.ends_with("test.kt")
316 || name.ends_with("test.scala")
317 || name.ends_with("test.rs")
318 || lower.contains("/test/")
319 || lower.contains("/tests/")
320 || lower.contains("/__tests__/")
321 || lower.contains("/spec/")
322}
323
324fn is_test_fragment(frag: &Fragment) -> bool {
325 if is_test_file(frag.path()) {
326 return true;
327 }
328 if let Some(ref sym) = frag.symbol_name {
329 return sym.to_lowercase().starts_with("test_");
330 }
331 false
332}
333
334pub fn match_strength_typed(frag: &Fragment, need: &InformationNeed) -> f64 {
335 let sym = &need.symbol;
336 let frag_sym = frag
337 .symbol_name
338 .as_ref()
339 .map(|s| s.to_lowercase())
340 .unwrap_or_default();
341 let defines = !frag_sym.is_empty() && frag_sym == *sym;
342 let mentions = frag.identifiers.contains(sym);
343 let scope_match =
344 need.scope.is_some() && need.scope.as_ref().map(|s| s.as_ref()) == Some(frag.path());
345 let nt = need.need_type.as_str();
346
347 if nt == "impact" && scope_match {
348 return NEEDS.impact_scope_match;
349 }
350 if defines && !frag.kind.is_signature() {
351 return defines_strength(scope_match, need.scope.is_some());
352 }
353 if nt == "impact" && mentions && !defines {
354 return NEEDS.impact_mentions;
355 }
356 if defines && (frag.kind.is_signature() || nt == "signature") {
357 return NEEDS.signature_defines;
358 }
359 if nt == "test" && mentions && is_test_fragment(frag) {
360 return NEEDS.test_mentions;
361 }
362 if mentions {
363 NEEDS.mentions_fallback
364 } else {
365 0.0
366 }
367}
368
369fn extract_changed_lines(diff_text: &str) -> Vec<String> {
370 let mut result = Vec::new();
371 for line in diff_text.lines() {
372 let is_added = line.starts_with('+') && !line.starts_with("+++");
373 let is_removed = line.starts_with('-') && !line.starts_with("---");
374 if is_added || is_removed {
375 result.push(line[1..].to_string());
376 }
377 }
378 result
379}
380
381fn path_suffix(path: &str) -> String {
382 std::path::Path::new(path)
383 .extension()
384 .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
385 .unwrap_or_default()
386}
387
388fn infer_core_symbol(frag: &Fragment) -> Option<String> {
389 if let Some(ref sym) = frag.symbol_name {
390 return Some(sym.to_lowercase());
391 }
392 let suffix = path_suffix(frag.path());
393 if ONE_CLASS_PER_FILE_SUFFIXES.contains(&suffix.as_str()) {
394 let stem = std::path::Path::new(frag.path())
395 .file_stem()
396 .map(|s| s.to_string_lossy().to_string());
397 if let Some(ref stem) = stem {
398 if stem.len() >= NEEDS.min_symbol_length {
399 return Some(stem.to_lowercase());
400 }
401 }
402 }
403 None
404}
405
406fn collect_core_needs(
407 all_fragments: &[Fragment],
408 core_ids: &FxHashSet<FragmentId>,
409 needs: &mut FxHashMap<(String, String), InformationNeed>,
410) -> FxHashSet<String> {
411 let mut core_symbol_names = FxHashSet::default();
412 let mut seen_paths = FxHashSet::default();
413 for frag in all_fragments {
414 if !core_ids.contains(&frag.id) {
415 continue;
416 }
417 let sym = match infer_core_symbol(frag) {
418 Some(s) => s,
419 None => continue,
420 };
421 if seen_paths.contains(frag.path()) && frag.symbol_name.is_none() {
422 continue;
423 }
424 if frag.symbol_name.is_none() {
425 seen_paths.insert(frag.path().to_string());
426 }
427 core_symbol_names.insert(sym.clone());
428 let key = ("impact".to_string(), sym.clone());
429 needs.entry(key).or_insert_with(|| InformationNeed {
430 need_type: "impact".to_string(),
431 symbol: sym,
432 scope: Some(frag.id.path.clone()),
433 priority: NEEDS.impact_priority,
434 });
435 }
436 core_symbol_names
437}
438
439fn process_line_for_needs(
440 line: &str,
441 external_syms: &FxHashSet<String>,
442 needs: &mut FxHashMap<(String, String), InformationNeed>,
443) {
444 for m in CALL_RE.captures_iter(line) {
445 let name = &m[1];
446 let low = name.to_lowercase();
447 if name.len() < NEEDS.min_symbol_length
448 || CODE_STOPWORDS.contains(&low)
449 || LANGUAGE_BUILTINS.contains(&low)
450 {
451 continue;
452 }
453 if external_syms.contains(&low) {
454 continue;
455 }
456 let key = ("definition".to_string(), low.clone());
457 needs.entry(key).or_insert_with(|| InformationNeed {
458 need_type: "definition".to_string(),
459 symbol: low,
460 scope: None,
461 priority: NEEDS.call_definition_priority,
462 });
463 }
464 for m in TYPE_REF_RE.captures_iter(line) {
465 let sym = m[1].to_lowercase();
466 let key = ("signature".to_string(), sym.clone());
467 needs.entry(key).or_insert_with(|| InformationNeed {
468 need_type: "signature".to_string(),
469 symbol: sym,
470 scope: None,
471 priority: NEEDS.signature_priority,
472 });
473 }
474 for m in GENERIC_TYPE_RE.captures_iter(line) {
475 let sym = m[1].to_lowercase();
476 let key = ("signature".to_string(), sym.clone());
477 needs.entry(key).or_insert_with(|| InformationNeed {
478 need_type: "signature".to_string(),
479 symbol: sym,
480 scope: None,
481 priority: NEEDS.signature_priority,
482 });
483 }
484}
485
486fn collect_diff_line_needs(
487 changed_lines: &[String],
488 needs: &mut FxHashMap<(String, String), InformationNeed>,
489) {
490 let line_refs: Vec<&str> = changed_lines.iter().map(|s| s.as_str()).collect();
491 let external_syms = collect_external_symbols_from_lines(&line_refs);
492 for line in changed_lines {
493 if !is_comment_line(line) {
494 process_line_for_needs(line, &external_syms, needs);
495 }
496 }
497}
498
499fn collect_test_needs(
500 all_fragments: &[Fragment],
501 core_symbol_names: &FxHashSet<String>,
502 needs: &mut FxHashMap<(String, String), InformationNeed>,
503) {
504 for frag in all_fragments {
505 if !is_test_fragment(frag) {
506 continue;
507 }
508 let tested = frag.symbol_name.as_ref().map(|s| {
509 let lower = s.to_lowercase();
510 lower.strip_prefix("test_").unwrap_or(&lower).to_string()
511 });
512 if let Some(ref tested) = tested {
513 if core_symbol_names.contains(tested)
514 || needs.contains_key(&("definition".to_string(), tested.clone()))
515 {
516 let key = ("test".to_string(), tested.clone());
517 needs.entry(key).or_insert_with(|| InformationNeed {
518 need_type: "test".to_string(),
519 symbol: tested.clone(),
520 scope: None,
521 priority: NEEDS.test_priority,
522 });
523 }
524 }
525 }
526}
527
528fn collect_invariant_needs(
529 changed_lines: &[String],
530 needs: &mut FxHashMap<(String, String), InformationNeed>,
531) {
532 for line in changed_lines {
533 for m in INVARIANT_RE.captures_iter(line) {
534 let sym = m[1].to_lowercase();
535 if sym.len() >= NEEDS.min_symbol_length && !CODE_STOPWORDS.contains(&sym) {
536 let key = ("invariant".to_string(), sym.clone());
537 needs.entry(key).or_insert_with(|| InformationNeed {
538 need_type: "invariant".to_string(),
539 symbol: sym,
540 scope: None,
541 priority: NEEDS.invariant_priority,
542 });
543 }
544 }
545 }
546}
547
548fn is_terraform_diff(all_fragments: &[Fragment], core_ids: &FxHashSet<FragmentId>) -> bool {
549 all_fragments.iter().any(|f| {
550 core_ids.contains(&f.id) && TF_EXTENSIONS.contains(&path_suffix(f.path()).as_str())
551 })
552}
553
554fn is_config_only_diff(all_fragments: &[Fragment], core_ids: &FxHashSet<FragmentId>) -> bool {
555 let core_frags: Vec<&Fragment> = all_fragments
556 .iter()
557 .filter(|f| core_ids.contains(&f.id))
558 .collect();
559 !core_frags.is_empty()
560 && core_frags
561 .iter()
562 .all(|f| CONFIG_EXTENSIONS_FOR_DIFF.contains(&path_suffix(f.path()).as_str()))
563}
564
565fn collect_config_context_needs(
566 all_fragments: &[Fragment],
567 core_ids: &FxHashSet<FragmentId>,
568 needs: &mut FxHashMap<(String, String), InformationNeed>,
569) {
570 let covered: FxHashSet<String> = needs.values().map(|n| n.symbol.clone()).collect();
571 for frag in all_fragments {
572 if !core_ids.contains(&frag.id) {
573 continue;
574 }
575 for ident in &frag.identifiers {
576 if ident.len() >= NEEDS.background_min_ident_length && !covered.contains(ident) {
577 let key = ("background".to_string(), ident.clone());
578 needs.entry(key).or_insert_with(|| InformationNeed {
579 need_type: "background".to_string(),
580 symbol: ident.clone(),
581 scope: None,
582 priority: NEEDS.background_priority,
583 });
584 }
585 }
586 }
587}
588
589fn collect_terraform_needs(
590 changed_lines: &[String],
591 needs: &mut FxHashMap<(String, String), InformationNeed>,
592) {
593 for line in changed_lines {
594 for m in TF_VAR_NEED_RE.captures_iter(line) {
595 let sym = m[1].to_lowercase();
596 if sym.len() >= NEEDS.min_symbol_length && !CODE_STOPWORDS.contains(&sym) {
597 let key = ("definition".to_string(), sym.clone());
598 needs.entry(key).or_insert_with(|| InformationNeed {
599 need_type: "definition".to_string(),
600 symbol: sym,
601 scope: None,
602 priority: NEEDS.call_definition_priority,
603 });
604 }
605 }
606 for m in TF_RES_REF_NEED_RE.captures_iter(line) {
607 let ref_type = m[1].to_lowercase();
608 let ref_name = m[2].to_lowercase();
609 if TF_SKIP_REF_TYPES.contains(&ref_type.as_str()) {
610 continue;
611 }
612 let full_ref = format!("{}.{}", ref_type, ref_name);
613 let key = ("definition".to_string(), full_ref.clone());
614 needs.entry(key).or_insert_with(|| InformationNeed {
615 need_type: "definition".to_string(),
616 symbol: full_ref,
617 scope: None,
618 priority: NEEDS.definition_priority,
619 });
620 }
621 }
622}
623
624pub fn concepts_from_diff_text(
625 diff_text: &str,
626 changed_lines: Option<&[String]>,
627) -> FxHashSet<String> {
628 let owned;
629 let lines = match changed_lines {
630 Some(l) => l,
631 None => {
632 owned = extract_changed_lines(diff_text);
633 &owned
634 }
635 };
636 let text = lines.join("\n");
637
638 let expansion_stopwords: FxHashSet<String> = CODE_STOPWORDS
639 .iter()
640 .chain(LANGUAGE_BUILTINS.iter())
641 .cloned()
642 .collect();
643
644 let raw = extract_identifiers(&text, NEEDS.min_symbol_length);
645 raw.into_iter()
646 .filter(|id| !expansion_stopwords.contains(id))
647 .collect()
648}
649
650pub fn needs_from_diff(
651 all_fragments: &[Fragment],
652 core_ids: &FxHashSet<FragmentId>,
653 diff_text: &str,
654) -> Vec<InformationNeed> {
655 let mut needs: FxHashMap<(String, String), InformationNeed> = FxHashMap::default();
656 let changed_lines = extract_changed_lines(diff_text);
657
658 let core_symbol_names = collect_core_needs(all_fragments, core_ids, &mut needs);
659 collect_diff_line_needs(&changed_lines, &mut needs);
660 collect_import_needs(
661 &changed_lines.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
662 &mut needs,
663 );
664 collect_invariant_needs(&changed_lines, &mut needs);
665 collect_test_needs(all_fragments, &core_symbol_names, &mut needs);
666 if is_terraform_diff(all_fragments, core_ids) {
667 collect_terraform_needs(&changed_lines, &mut needs);
668 }
669 if is_config_only_diff(all_fragments, core_ids) {
670 collect_config_context_needs(all_fragments, core_ids, &mut needs);
671 }
672
673 if needs.is_empty() {
674 let fallback = concepts_from_diff_text(diff_text, Some(&changed_lines));
675 return fallback
676 .into_iter()
677 .map(|c| InformationNeed {
678 need_type: "definition".to_string(),
679 symbol: c,
680 scope: None,
681 priority: NEEDS.fallback_priority,
682 })
683 .collect();
684 }
685
686 let covered_symbols: FxHashSet<String> = needs.values().map(|n| n.symbol.clone()).collect();
687 for c in concepts_from_diff_text(diff_text, Some(&changed_lines)) {
688 if !covered_symbols.contains(&c) {
689 let key = ("background".to_string(), c.clone());
690 needs.entry(key).or_insert_with(|| InformationNeed {
691 need_type: "background".to_string(),
692 symbol: c,
693 scope: None,
694 priority: NEEDS.concept_background_priority,
695 });
696 }
697 }
698
699 needs.into_values().collect()
700}