1use std::path::{Path, PathBuf};
2
3const 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 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}