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 crate::testfiles::is_test_path(std::path::Path::new(path))
298}
299
300fn is_test_fragment(frag: &Fragment) -> bool {
301 if is_test_file(frag.path()) {
302 return true;
303 }
304 if let Some(ref sym) = frag.symbol_name {
305 return sym.to_lowercase().starts_with("test_");
306 }
307 false
308}
309
310pub fn match_strength_typed(frag: &Fragment, need: &InformationNeed) -> f64 {
311 let sym = &need.symbol;
312 let frag_sym = frag
313 .symbol_name
314 .as_ref()
315 .map(|s| s.to_lowercase())
316 .unwrap_or_default();
317 let defines = !frag_sym.is_empty() && frag_sym == *sym;
318 let mentions = frag.identifiers.contains(sym);
319 let scope_match =
320 need.scope.is_some() && need.scope.as_ref().map(|s| s.as_ref()) == Some(frag.path());
321 let nt = need.need_type.as_str();
322
323 if nt == "impact" && scope_match {
324 return NEEDS.impact_scope_match;
325 }
326 if defines && !frag.kind.is_signature() {
327 return defines_strength(scope_match, need.scope.is_some());
328 }
329 if nt == "impact" && mentions && !defines {
330 return NEEDS.impact_mentions;
331 }
332 if defines && (frag.kind.is_signature() || nt == "signature") {
333 return NEEDS.signature_defines;
334 }
335 if nt == "test" && mentions && is_test_fragment(frag) {
336 return NEEDS.test_mentions;
337 }
338 if mentions {
339 NEEDS.mentions_fallback
340 } else {
341 0.0
342 }
343}
344
345fn extract_changed_lines(diff_text: &str) -> Vec<String> {
346 let mut result = Vec::new();
347 for line in diff_text.lines() {
348 let is_added = line.starts_with('+') && !line.starts_with("+++");
349 let is_removed = line.starts_with('-') && !line.starts_with("---");
350 if is_added || is_removed {
351 result.push(line[1..].to_string());
352 }
353 }
354 result
355}
356
357fn path_suffix(path: &str) -> String {
358 std::path::Path::new(path)
359 .extension()
360 .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
361 .unwrap_or_default()
362}
363
364fn infer_core_symbol(frag: &Fragment) -> Option<String> {
365 if let Some(ref sym) = frag.symbol_name {
366 return Some(sym.to_lowercase());
367 }
368 let suffix = path_suffix(frag.path());
369 if ONE_CLASS_PER_FILE_SUFFIXES.contains(&suffix.as_str()) {
370 let stem = std::path::Path::new(frag.path())
371 .file_stem()
372 .map(|s| s.to_string_lossy().to_string());
373 if let Some(ref stem) = stem {
374 if stem.len() >= NEEDS.min_symbol_length {
375 return Some(stem.to_lowercase());
376 }
377 }
378 }
379 None
380}
381
382fn collect_core_needs(
383 all_fragments: &[Fragment],
384 core_ids: &FxHashSet<FragmentId>,
385 needs: &mut FxHashMap<(String, String), InformationNeed>,
386) -> FxHashSet<String> {
387 let mut core_symbol_names = FxHashSet::default();
388 let mut seen_paths = FxHashSet::default();
389 for frag in all_fragments {
390 if !core_ids.contains(&frag.id) {
391 continue;
392 }
393 let sym = match infer_core_symbol(frag) {
394 Some(s) => s,
395 None => continue,
396 };
397 if seen_paths.contains(frag.path()) && frag.symbol_name.is_none() {
398 continue;
399 }
400 if frag.symbol_name.is_none() {
401 seen_paths.insert(frag.path().to_string());
402 }
403 core_symbol_names.insert(sym.clone());
404 let key = ("impact".to_string(), sym.clone());
405 needs.entry(key).or_insert_with(|| InformationNeed {
406 need_type: "impact".to_string(),
407 symbol: sym,
408 scope: Some(frag.id.path.clone()),
409 priority: NEEDS.impact_priority,
410 });
411 }
412 core_symbol_names
413}
414
415fn process_line_for_needs(
416 line: &str,
417 external_syms: &FxHashSet<String>,
418 needs: &mut FxHashMap<(String, String), InformationNeed>,
419) {
420 for m in CALL_RE.captures_iter(line) {
421 let name = &m[1];
422 let low = name.to_lowercase();
423 if name.len() < NEEDS.min_symbol_length
424 || CODE_STOPWORDS.contains(&low)
425 || LANGUAGE_BUILTINS.contains(&low)
426 {
427 continue;
428 }
429 if external_syms.contains(&low) {
430 continue;
431 }
432 let key = ("definition".to_string(), low.clone());
433 needs.entry(key).or_insert_with(|| InformationNeed {
434 need_type: "definition".to_string(),
435 symbol: low,
436 scope: None,
437 priority: NEEDS.call_definition_priority,
438 });
439 }
440 for m in TYPE_REF_RE.captures_iter(line) {
449 add_signature_need(&m[1], external_syms, needs);
450 }
451 for m in GENERIC_TYPE_RE.captures_iter(line) {
452 add_signature_need(&m[1], external_syms, needs);
453 }
454}
455
456fn add_signature_need(
457 raw: &str,
458 external_syms: &FxHashSet<String>,
459 needs: &mut FxHashMap<(String, String), InformationNeed>,
460) {
461 let sym = raw.to_lowercase();
462 if external_syms.contains(&sym) {
463 return;
464 }
465 let key = ("signature".to_string(), sym.clone());
466 needs.entry(key).or_insert_with(|| InformationNeed {
467 need_type: "signature".to_string(),
468 symbol: sym,
469 scope: None,
470 priority: NEEDS.signature_priority,
471 });
472}
473
474fn collect_diff_line_needs(
475 changed_lines: &[String],
476 needs: &mut FxHashMap<(String, String), InformationNeed>,
477) {
478 let line_refs: Vec<&str> = changed_lines.iter().map(|s| s.as_str()).collect();
479 let external_syms = collect_external_symbols_from_lines(&line_refs);
480 for line in changed_lines {
481 if !is_comment_line(line) {
482 process_line_for_needs(line, &external_syms, needs);
483 }
484 }
485}
486
487fn collect_test_needs(
488 all_fragments: &[Fragment],
489 core_symbol_names: &FxHashSet<String>,
490 needs: &mut FxHashMap<(String, String), InformationNeed>,
491) {
492 for frag in all_fragments {
493 if !is_test_fragment(frag) {
494 continue;
495 }
496 let tested = frag.symbol_name.as_ref().map(|s| {
497 let lower = s.to_lowercase();
498 lower.strip_prefix("test_").unwrap_or(&lower).to_string()
499 });
500 if let Some(ref tested) = tested {
501 if core_symbol_names.contains(tested)
502 || needs.contains_key(&("definition".to_string(), tested.clone()))
503 {
504 let key = ("test".to_string(), tested.clone());
505 needs.entry(key).or_insert_with(|| InformationNeed {
506 need_type: "test".to_string(),
507 symbol: tested.clone(),
508 scope: None,
509 priority: NEEDS.test_priority,
510 });
511 }
512 }
513 }
514}
515
516fn collect_invariant_needs(
517 changed_lines: &[String],
518 needs: &mut FxHashMap<(String, String), InformationNeed>,
519) {
520 for line in changed_lines {
521 for m in INVARIANT_RE.captures_iter(line) {
522 let sym = m[1].to_lowercase();
523 if sym.len() >= NEEDS.min_symbol_length && !CODE_STOPWORDS.contains(&sym) {
524 let key = ("invariant".to_string(), sym.clone());
525 needs.entry(key).or_insert_with(|| InformationNeed {
526 need_type: "invariant".to_string(),
527 symbol: sym,
528 scope: None,
529 priority: NEEDS.invariant_priority,
530 });
531 }
532 }
533 }
534}
535
536fn is_terraform_diff(all_fragments: &[Fragment], core_ids: &FxHashSet<FragmentId>) -> bool {
537 all_fragments.iter().any(|f| {
538 core_ids.contains(&f.id) && TF_EXTENSIONS.contains(&path_suffix(f.path()).as_str())
539 })
540}
541
542fn is_config_only_diff(all_fragments: &[Fragment], core_ids: &FxHashSet<FragmentId>) -> bool {
543 let core_frags: Vec<&Fragment> = all_fragments
544 .iter()
545 .filter(|f| core_ids.contains(&f.id))
546 .collect();
547 !core_frags.is_empty()
548 && core_frags
549 .iter()
550 .all(|f| CONFIG_EXTENSIONS_FOR_DIFF.contains(&path_suffix(f.path()).as_str()))
551}
552
553fn collect_config_context_needs(
554 all_fragments: &[Fragment],
555 core_ids: &FxHashSet<FragmentId>,
556 needs: &mut FxHashMap<(String, String), InformationNeed>,
557) {
558 let covered: FxHashSet<String> = needs.values().map(|n| n.symbol.clone()).collect();
559 for frag in all_fragments {
560 if !core_ids.contains(&frag.id) {
561 continue;
562 }
563 for ident in &frag.identifiers {
564 if ident.len() >= NEEDS.background_min_ident_length && !covered.contains(ident) {
565 let key = ("background".to_string(), ident.clone());
566 needs.entry(key).or_insert_with(|| InformationNeed {
567 need_type: "background".to_string(),
568 symbol: ident.clone(),
569 scope: None,
570 priority: NEEDS.background_priority,
571 });
572 }
573 }
574 }
575}
576
577fn collect_terraform_needs(
578 changed_lines: &[String],
579 needs: &mut FxHashMap<(String, String), InformationNeed>,
580) {
581 for line in changed_lines {
582 for m in TF_VAR_NEED_RE.captures_iter(line) {
583 let sym = m[1].to_lowercase();
584 if sym.len() >= NEEDS.min_symbol_length && !CODE_STOPWORDS.contains(&sym) {
585 let key = ("definition".to_string(), sym.clone());
586 needs.entry(key).or_insert_with(|| InformationNeed {
587 need_type: "definition".to_string(),
588 symbol: sym,
589 scope: None,
590 priority: NEEDS.call_definition_priority,
591 });
592 }
593 }
594 for m in TF_RES_REF_NEED_RE.captures_iter(line) {
595 let ref_type = m[1].to_lowercase();
596 let ref_name = m[2].to_lowercase();
597 if TF_SKIP_REF_TYPES.contains(&ref_type.as_str()) {
598 continue;
599 }
600 let full_ref = format!("{}.{}", ref_type, ref_name);
601 let key = ("definition".to_string(), full_ref.clone());
602 needs.entry(key).or_insert_with(|| InformationNeed {
603 need_type: "definition".to_string(),
604 symbol: full_ref,
605 scope: None,
606 priority: NEEDS.definition_priority,
607 });
608 }
609 }
610}
611
612pub fn concepts_from_diff_text(
613 diff_text: &str,
614 changed_lines: Option<&[String]>,
615) -> FxHashSet<String> {
616 let owned;
617 let lines = match changed_lines {
618 Some(l) => l,
619 None => {
620 owned = extract_changed_lines(diff_text);
621 &owned
622 }
623 };
624 let text = lines.join("\n");
625
626 let expansion_stopwords: FxHashSet<String> = CODE_STOPWORDS
627 .iter()
628 .chain(LANGUAGE_BUILTINS.iter())
629 .cloned()
630 .collect();
631
632 let raw = extract_identifiers(&text, NEEDS.min_symbol_length);
633 raw.into_iter()
634 .filter(|id| !expansion_stopwords.contains(id))
635 .collect()
636}
637
638pub fn needs_from_diff(
639 all_fragments: &[Fragment],
640 core_ids: &FxHashSet<FragmentId>,
641 diff_text: &str,
642) -> Vec<InformationNeed> {
643 let mut needs: FxHashMap<(String, String), InformationNeed> = FxHashMap::default();
644 let changed_lines = extract_changed_lines(diff_text);
645
646 let core_symbol_names = collect_core_needs(all_fragments, core_ids, &mut needs);
647 collect_diff_line_needs(&changed_lines, &mut needs);
648 collect_import_needs(
649 &changed_lines.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
650 &mut needs,
651 );
652 collect_invariant_needs(&changed_lines, &mut needs);
653 collect_test_needs(all_fragments, &core_symbol_names, &mut needs);
654 if is_terraform_diff(all_fragments, core_ids) {
655 collect_terraform_needs(&changed_lines, &mut needs);
656 }
657 if is_config_only_diff(all_fragments, core_ids) {
658 collect_config_context_needs(all_fragments, core_ids, &mut needs);
659 }
660
661 if needs.is_empty() {
662 let fallback = concepts_from_diff_text(diff_text, Some(&changed_lines));
663 return fallback
664 .into_iter()
665 .map(|c| InformationNeed {
666 need_type: "definition".to_string(),
667 symbol: c,
668 scope: None,
669 priority: NEEDS.fallback_priority,
670 })
671 .collect();
672 }
673
674 let covered_symbols: FxHashSet<String> = needs.values().map(|n| n.symbol.clone()).collect();
675 for c in concepts_from_diff_text(diff_text, Some(&changed_lines)) {
676 if !covered_symbols.contains(&c) {
677 let key = ("background".to_string(), c.clone());
678 needs.entry(key).or_insert_with(|| InformationNeed {
679 need_type: "background".to_string(),
680 symbol: c,
681 scope: None,
682 priority: NEEDS.concept_background_priority,
683 });
684 }
685 }
686
687 needs.into_values().collect()
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693
694 fn signature_symbols(diff_text: &str) -> FxHashSet<String> {
695 needs_from_diff(&[], &FxHashSet::default(), diff_text)
696 .into_iter()
697 .filter(|n| n.need_type == "signature")
698 .map(|n| n.symbol)
699 .collect()
700 }
701
702 #[test]
709 fn an_externally_imported_type_does_not_become_a_signature_need() {
710 let diff = concat!(
711 "+from typing import Optional\n",
712 "+def handle(x: Optional[int]) -> LocalResult:\n",
713 );
714 let syms = signature_symbols(diff);
715 assert!(
716 !syms.contains("optional"),
717 "an external type became a need: {syms:?}"
718 );
719 assert!(
720 syms.contains("localresult"),
721 "the repository-local type was lost: {syms:?}"
722 );
723 }
724
725 #[test]
726 fn an_externally_imported_generic_argument_is_skipped_too() {
727 let diff = concat!(
728 "+import { Observable } from 'rxjs'\n",
729 "+const s: Array<Observable> = wrap<LocalThing>(x)\n",
730 );
731 let syms = signature_symbols(diff);
732 assert!(
733 !syms.contains("observable"),
734 "an external generic argument became a need: {syms:?}"
735 );
736 assert!(
737 syms.contains("localthing"),
738 "the repository-local generic argument was lost: {syms:?}"
739 );
740 }
741}