Skip to main content

_diffctx/utility/
needs.rs

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}