Skip to main content

_diffctx/edges/semantic/
javascript.rs

1use std::path::{Path, PathBuf};
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use rustc_hash::{FxHashMap, FxHashSet};
6
7use crate::config::edge_weights::JAVASCRIPT_SEMANTIC;
8use crate::config::extensions::{JS_TS_EXTENSIONS, TYPESCRIPT_EXTENSIONS};
9use crate::config::weights::LANG_WEIGHTS;
10use crate::types::{Fragment, FragmentId};
11
12use super::super::EdgeDict;
13use super::super::base::{self, EdgeBuilder};
14
15fn is_js_file(path: &Path) -> bool {
16    let ext = base::file_ext(path);
17    JS_TS_EXTENSIONS.contains(ext.as_str())
18}
19
20fn is_ts_file(path: &Path) -> bool {
21    let ext = base::file_ext(path);
22    TYPESCRIPT_EXTENSIONS.contains(ext.as_str())
23}
24
25static IMPORT_SOURCE_RE: Lazy<Regex> = Lazy::new(|| {
26    Regex::new(
27        r#"(?m)(?:import\s+.*?\s+from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))"#,
28    )
29    .unwrap()
30});
31static EXPORT_RE: Lazy<Regex> = Lazy::new(|| {
32    Regex::new(r"(?m)^\s*export\s+(?:default\s+)?(?:function|class|const|let|var|interface|type|enum|abstract\s+class)\s+([A-Za-z_$]\w*)").unwrap()
33});
34static NAMED_IMPORT_NAMES_RE: Lazy<Regex> =
35    Lazy::new(|| Regex::new(r"(?m)import\s*\{([^}]+)\}\s*from").unwrap());
36static CALL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b([A-Za-z_$]\w*)\s*\(").unwrap());
37static TYPE_REF_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b([A-Z]\w*)\b").unwrap());
38static DEF_RE: Lazy<Regex> = Lazy::new(|| {
39    Regex::new(r"(?m)^\s*(?:export\s+)?(?:default\s+)?(?:function|class|const|let|var|interface|type|enum)\s+([A-Za-z_$]\w*)").unwrap()
40});
41
42static JS_KEYWORDS: Lazy<FxHashSet<&str>> = Lazy::new(|| {
43    [
44        "if",
45        "for",
46        "while",
47        "return",
48        "function",
49        "class",
50        "const",
51        "let",
52        "var",
53        "new",
54        "delete",
55        "typeof",
56        "instanceof",
57        "void",
58        "switch",
59        "case",
60        "break",
61        "continue",
62        "throw",
63        "try",
64        "catch",
65        "finally",
66        "yield",
67        "async",
68        "await",
69        "import",
70        "export",
71        "default",
72        "from",
73        "require",
74        "super",
75        "this",
76        "true",
77        "false",
78        "null",
79        "undefined",
80        "console",
81        "Math",
82        "Object",
83        "Array",
84        "String",
85        "Number",
86        "Boolean",
87        "Error",
88        "Promise",
89        "Map",
90        "Set",
91        "Date",
92        "JSON",
93        "RegExp",
94        "Symbol",
95        "parseInt",
96        "parseFloat",
97        "setTimeout",
98        "setInterval",
99        "clearTimeout",
100        "clearInterval",
101    ]
102    .iter()
103    .copied()
104    .collect()
105});
106
107fn extract_import_sources(content: &str) -> FxHashSet<String> {
108    let mut sources = FxHashSet::default();
109    for cap in IMPORT_SOURCE_RE.captures_iter(content) {
110        if let Some(m) = cap.get(1) {
111            sources.insert(m.as_str().to_string());
112        }
113        if let Some(m) = cap.get(2) {
114            sources.insert(m.as_str().to_string());
115        }
116    }
117    sources
118}
119
120fn extract_defines(content: &str) -> FxHashSet<String> {
121    DEF_RE
122        .captures_iter(content)
123        .map(|c| c[1].to_string())
124        .collect()
125}
126
127fn extract_calls(content: &str) -> FxHashSet<String> {
128    CALL_RE
129        .captures_iter(content)
130        .map(|c| c[1].to_string())
131        .filter(|n| !JS_KEYWORDS.contains(n.as_str()))
132        .collect()
133}
134
135fn extract_type_refs(content: &str) -> FxHashSet<String> {
136    TYPE_REF_RE
137        .captures_iter(content)
138        .map(|c| c[1].to_string())
139        .collect()
140}
141
142fn extract_exports(content: &str) -> FxHashSet<String> {
143    let mut exported = FxHashSet::default();
144    for cap in EXPORT_RE.captures_iter(content) {
145        exported.insert(cap[1].to_lowercase());
146    }
147    exported
148}
149
150fn resolve_relative_import(
151    src_path: &Path,
152    import_source: &str,
153    known_paths: &FxHashSet<PathBuf>,
154) -> Option<PathBuf> {
155    if !import_source.starts_with('.') {
156        return None;
157    }
158    let base = src_path.parent()?;
159    let candidate_base = base.join(import_source);
160    let candidate_base = candidate_base.as_path();
161
162    for ext in JS_TS_EXTENSIONS.iter() {
163        let with_ext = candidate_base.with_extension(&ext[1..]);
164        if known_paths.contains(&with_ext) {
165            return Some(with_ext);
166        }
167    }
168
169    for index_name in &["index.ts", "index.tsx", "index.js", "index.jsx"] {
170        let idx = candidate_base.join(index_name);
171        if known_paths.contains(&idx) {
172            return Some(idx);
173        }
174    }
175
176    None
177}
178
179pub struct JavaScriptEdgeBuilder;
180
181impl EdgeBuilder for JavaScriptEdgeBuilder {
182    fn build(&self, fragments: &[Fragment], _repo_root: Option<&Path>) -> EdgeDict {
183        let js_frags: Vec<&Fragment> = fragments
184            .iter()
185            .filter(|f| is_js_file(Path::new(f.path())))
186            .collect();
187        if js_frags.is_empty() {
188            return FxHashMap::default();
189        }
190
191        let mut name_to_defs: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
192        let mut frag_defines: FxHashMap<FragmentId, FxHashSet<String>> = FxHashMap::default();
193
194        for f in &js_frags {
195            let defines = extract_defines(&f.content);
196            for name in &defines {
197                name_to_defs
198                    .entry(name.clone())
199                    .or_default()
200                    .push(f.id.clone());
201            }
202            frag_defines.insert(f.id.clone(), defines);
203        }
204
205        let mut edges: EdgeDict = FxHashMap::default();
206
207        for f in &js_frags {
208            let self_defs = frag_defines.get(&f.id).cloned().unwrap_or_default();
209            let is_ts = is_ts_file(Path::new(f.path()));
210            let w = if is_ts {
211                LANG_WEIGHTS.get("typescript").expect("ts weights")
212            } else {
213                LANG_WEIGHTS.get("javascript").expect("js weights")
214            };
215
216            let calls = extract_calls(&f.content);
217            let type_refs = extract_type_refs(&f.content);
218
219            for (ref_set, base_weight) in [(&calls, w.call), (&type_refs, w.type_ref)] {
220                for name in ref_set {
221                    if self_defs.contains(name) {
222                        continue;
223                    }
224                    if let Some(dst_ids) = name_to_defs.get(name) {
225                        for dst_id in dst_ids {
226                            if dst_id == &f.id {
227                                continue;
228                            }
229                            base::add_edge(
230                                &mut edges,
231                                &f.id,
232                                dst_id,
233                                base_weight,
234                                JAVASCRIPT_SEMANTIC.reverse_factor,
235                            );
236                        }
237                    }
238                }
239            }
240        }
241
242        let mut file_to_frags: FxHashMap<PathBuf, Vec<FragmentId>> = FxHashMap::default();
243        for f in &js_frags {
244            file_to_frags
245                .entry(PathBuf::from(f.path()))
246                .or_default()
247                .push(f.id.clone());
248        }
249        let fragment_paths: FxHashSet<PathBuf> = file_to_frags.keys().cloned().collect();
250
251        for f in &js_frags {
252            let src_path = PathBuf::from(f.path());
253            let import_sources = extract_import_sources(&f.content);
254            for source in &import_sources {
255                if !source.starts_with('.') {
256                    continue;
257                }
258                if let Some(resolved) = resolve_relative_import(&src_path, source, &fragment_paths)
259                {
260                    if resolved == src_path {
261                        continue;
262                    }
263                    if let Some(target_ids) = file_to_frags.get(&resolved) {
264                        if let Some(src_ids) = file_to_frags.get(&src_path) {
265                            for src_id in src_ids {
266                                for tgt_id in target_ids {
267                                    if tgt_id != src_id {
268                                        base::add_edge(
269                                            &mut edges,
270                                            src_id,
271                                            tgt_id,
272                                            JAVASCRIPT_SEMANTIC.import_weight,
273                                            JAVASCRIPT_SEMANTIC.reverse_factor,
274                                        );
275                                    }
276                                }
277                            }
278                        }
279                    }
280                }
281            }
282        }
283
284        edges
285    }
286
287    fn discover_related_files(
288        &self,
289        changed: &[PathBuf],
290        candidates: &[PathBuf],
291        repo_root: Option<&Path>,
292        _file_cache: Option<&FxHashMap<PathBuf, String>>,
293    ) -> Vec<PathBuf> {
294        let js_changed: Vec<&PathBuf> = changed.iter().filter(|f| is_js_file(f)).collect();
295        if js_changed.is_empty() {
296            return vec![];
297        }
298
299        let changed_set: FxHashSet<PathBuf> = changed.iter().cloned().collect();
300        let mut discovered: FxHashSet<PathBuf> = FxHashSet::default();
301        let mut frontier: Vec<PathBuf> = js_changed.iter().map(|f| (*f).clone()).collect();
302
303        for _ in 0..2 {
304            let mut newly_found: FxHashSet<PathBuf> = FxHashSet::default();
305
306            let mut changed_names: FxHashSet<String> = FxHashSet::default();
307            for f in &frontier {
308                let stem = f
309                    .file_stem()
310                    .map(|s| s.to_string_lossy().to_lowercase())
311                    .unwrap_or_default();
312                changed_names.insert(stem.clone());
313                if stem == "index" {
314                    if let Some(parent) = f.parent() {
315                        if let Some(name) = parent.file_name() {
316                            changed_names.insert(name.to_string_lossy().to_lowercase());
317                        }
318                    }
319                }
320                if let Some(root) = repo_root {
321                    if let Ok(rel) = f.strip_prefix(root) {
322                        let rel_str = rel.with_extension("").to_string_lossy().replace('\\', "/");
323                        changed_names.insert(rel_str);
324                    }
325                }
326            }
327
328            for candidate in candidates {
329                if changed_set.contains(candidate)
330                    || discovered.contains(candidate)
331                    || !is_js_file(candidate)
332                {
333                    continue;
334                }
335                if let Ok(content) = std::fs::read_to_string(candidate) {
336                    let imports = extract_import_sources(&content);
337                    for imp in &imports {
338                        let imp_lower = imp.to_lowercase();
339                        if changed_names.iter().any(|n| {
340                            n.len() >= 3
341                                && (imp_lower.contains(n.as_str())
342                                    || imp_lower.ends_with(n.as_str()))
343                        }) {
344                            newly_found.insert(candidate.clone());
345                            break;
346                        }
347                    }
348                }
349            }
350
351            let exported_names: FxHashSet<String> = frontier
352                .iter()
353                .filter_map(|f| std::fs::read_to_string(f).ok())
354                .flat_map(|c| extract_exports(&c))
355                .collect();
356
357            if !exported_names.is_empty() {
358                for candidate in candidates {
359                    if changed_set.contains(candidate)
360                        || discovered.contains(candidate)
361                        || newly_found.contains(candidate)
362                        || !is_js_file(candidate)
363                    {
364                        continue;
365                    }
366                    if let Ok(content) = std::fs::read_to_string(candidate) {
367                        for cap in NAMED_IMPORT_NAMES_RE.captures_iter(&content) {
368                            let names: FxHashSet<String> = cap[1]
369                                .split(',')
370                                .filter_map(|n| {
371                                    let trimmed =
372                                        n.trim().split(" as ").next()?.trim().to_lowercase();
373                                    if trimmed.is_empty() {
374                                        None
375                                    } else {
376                                        Some(trimmed)
377                                    }
378                                })
379                                .collect();
380                            if !names.is_disjoint(&exported_names) {
381                                newly_found.insert(candidate.clone());
382                                break;
383                            }
384                        }
385                    }
386                }
387            }
388
389            let candidate_set: FxHashSet<PathBuf> = candidates.iter().cloned().collect();
390            for f in &frontier {
391                if let Ok(content) = std::fs::read_to_string(f) {
392                    let sources = extract_import_sources(&content);
393                    for source in sources {
394                        if source.starts_with('.') {
395                            if let Some(resolved) =
396                                resolve_relative_import(f, &source, &candidate_set)
397                            {
398                                if !changed_set.contains(&resolved)
399                                    && !discovered.contains(&resolved)
400                                {
401                                    newly_found.insert(resolved);
402                                }
403                            }
404                        }
405                    }
406                }
407            }
408
409            if newly_found.is_empty() {
410                break;
411            }
412            discovered.extend(newly_found.iter().cloned());
413            frontier = newly_found.into_iter().filter(|f| is_js_file(f)).collect();
414        }
415
416        let mut result: Vec<PathBuf> = discovered.into_iter().collect();
417        result.sort();
418        result
419    }
420}