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}