Skip to main content

_diffctx/edges/semantic/
javascript.rs

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