codelens_engine/call_graph/
js_imports.rs1use crate::project::ProjectRoot;
2use regex::Regex;
3use std::collections::{HashMap, HashSet, VecDeque};
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::LazyLock;
7
8use super::types::CallEdge;
9
10static JS_IMPORT_FROM_RE: LazyLock<Regex> = LazyLock::new(|| {
11 Regex::new(r#"(?m)\bimport\s+([^;]+?)\s+from\s+["']([^"']+)["']"#).expect("import regex")
12});
13static JS_REEXPORT_FROM_RE: LazyLock<Regex> = LazyLock::new(|| {
14 Regex::new(r#"(?m)\bexport\s+([^;]+?)\s+from\s+["']([^"']+)["']"#).expect("re-export regex")
15});
16
17#[derive(Debug)]
18pub(crate) struct LocalBindingScope {
19 pub(crate) start_byte: usize,
20 pub(crate) end_byte: usize,
21 pub(crate) names: HashSet<String>,
22}
23
24#[derive(Debug, Clone)]
25pub(crate) struct JSImportBinding {
26 pub(crate) imported_name: Option<String>,
27 pub(crate) resolved_file: Option<String>,
28 pub(crate) external: bool,
29}
30
31pub(crate) type JSImportBindingIndex = HashMap<String, HashMap<String, JSImportBinding>>;
32pub(crate) fn is_import_sensitive_path(path: &str) -> bool {
33 matches!(
34 Path::new(path)
35 .extension()
36 .and_then(|value| value.to_str())
37 .unwrap_or_default(),
38 "js" | "jsx" | "ts" | "tsx"
39 )
40}
41
42fn is_external_module_specifier(module: &str, resolved_file: Option<&String>) -> bool {
43 resolved_file.is_none() && !module.starts_with('.') && !module.starts_with('/')
44}
45
46fn insert_js_binding(
47 bindings: &mut HashMap<String, JSImportBinding>,
48 local_name: &str,
49 imported_name: Option<&str>,
50 resolved_file: Option<&String>,
51 external: bool,
52) {
53 let local_name = local_name.trim().trim_start_matches("type ").trim();
54 if local_name.is_empty() {
55 return;
56 }
57 bindings.insert(
58 local_name.to_owned(),
59 JSImportBinding {
60 imported_name: imported_name
61 .map(|value| value.trim().trim_start_matches("type ").to_owned()),
62 resolved_file: resolved_file.cloned(),
63 external,
64 },
65 );
66}
67
68fn parse_js_import_bindings(
69 bindings: &mut HashMap<String, JSImportBinding>,
70 clause: &str,
71 resolved_file: Option<&String>,
72 module: &str,
73) {
74 let clause = clause.trim().trim_start_matches("type ").trim();
75 if clause.is_empty() {
76 return;
77 }
78 let external = is_external_module_specifier(module, resolved_file);
79
80 if let Some(stripped) = clause.strip_prefix("* as ") {
81 insert_js_binding(bindings, stripped, Some("*"), resolved_file, external);
82 return;
83 }
84
85 let mut default_part = clause;
86 if let Some(start) = clause.find('{') {
87 default_part = clause[..start].trim().trim_end_matches(',').trim();
88 if let Some(end) = clause[start + 1..].find('}') {
89 let named = &clause[start + 1..start + 1 + end];
90 for item in named.split(',') {
91 let item = item.trim().trim_start_matches("type ").trim();
92 if item.is_empty() {
93 continue;
94 }
95 if let Some((imported, local)) = item.split_once(" as ") {
96 insert_js_binding(bindings, local, Some(imported), resolved_file, external);
97 } else {
98 insert_js_binding(bindings, item, Some(item), resolved_file, external);
99 }
100 }
101 }
102 }
103
104 if !default_part.is_empty() {
105 insert_js_binding(bindings, default_part, None, resolved_file, external);
106 }
107}
108
109fn parse_js_reexport_bindings(
110 bindings: &mut HashMap<String, JSImportBinding>,
111 clause: &str,
112 resolved_file: Option<&String>,
113 module: &str,
114) {
115 let clause = clause.trim().trim_start_matches("type ").trim();
116 let external = is_external_module_specifier(module, resolved_file);
117
118 if clause == "*" {
119 insert_js_binding(bindings, "*", Some("*"), resolved_file, external);
120 return;
121 }
122
123 if let Some(stripped) = clause.strip_prefix("* as ") {
124 insert_js_binding(bindings, stripped, Some("*"), resolved_file, external);
125 return;
126 }
127
128 if !clause.starts_with('{') {
129 return;
130 }
131 let Some(end) = clause.find('}') else {
132 return;
133 };
134
135 for item in clause[1..end].split(',') {
136 let item = item.trim().trim_start_matches("type ").trim();
137 if item.is_empty() {
138 continue;
139 }
140 if let Some((imported, local)) = item.split_once(" as ") {
141 insert_js_binding(bindings, local, Some(imported), resolved_file, external);
142 } else {
143 insert_js_binding(bindings, item, Some(item), resolved_file, external);
144 }
145 }
146}
147
148pub(crate) fn build_js_import_binding_index(
149 project: &ProjectRoot,
150 files: &[PathBuf],
151) -> JSImportBindingIndex {
152 let mut index = HashMap::new();
153 let mut queue: VecDeque<(PathBuf, usize)> =
154 files.iter().cloned().map(|file| (file, 0)).collect();
155 let mut seen = HashSet::new();
156 while let Some((file, depth)) = queue.pop_front() {
157 let relative = project.to_relative(&file);
158 if !seen.insert(relative.clone()) {
159 continue;
160 }
161 if !is_import_sensitive_path(&relative) {
162 continue;
163 }
164 let Ok(source) = fs::read_to_string(&file) else {
165 continue;
166 };
167 let mut bindings = HashMap::new();
168 for capture in JS_IMPORT_FROM_RE.captures_iter(&source) {
169 let Some(clause) = capture.get(1).map(|value| value.as_str()) else {
170 continue;
171 };
172 let Some(module) = capture.get(2).map(|value| value.as_str()) else {
173 continue;
174 };
175 let resolved_file =
176 crate::import_graph::resolve_module_for_file(project, &file, module);
177 if depth == 0
178 && let Some(resolved_file) = resolved_file.as_ref()
179 && let Ok(resolved_path) = project.resolve(resolved_file)
180 {
181 queue.push_back((resolved_path, 1));
182 }
183 parse_js_import_bindings(&mut bindings, clause, resolved_file.as_ref(), module);
184 }
185 for capture in JS_REEXPORT_FROM_RE.captures_iter(&source) {
186 let Some(clause) = capture.get(1).map(|value| value.as_str()) else {
187 continue;
188 };
189 let Some(module) = capture.get(2).map(|value| value.as_str()) else {
190 continue;
191 };
192 let resolved_file =
193 crate::import_graph::resolve_module_for_file(project, &file, module);
194 if depth == 0
195 && let Some(resolved_file) = resolved_file.as_ref()
196 && let Ok(resolved_path) = project.resolve(resolved_file)
197 {
198 queue.push_back((resolved_path, 1));
199 }
200 parse_js_reexport_bindings(&mut bindings, clause, resolved_file.as_ref(), module);
201 }
202 if !bindings.is_empty() {
203 index.insert(relative, bindings);
204 }
205 }
206 index
207}
208
209pub(crate) fn filter_external_import_edges(
210 edges: &mut Vec<CallEdge>,
211 import_bindings: &JSImportBindingIndex,
212) {
213 edges.retain(|edge| {
214 let binding_name = edge
215 .callee_qualifier
216 .as_deref()
217 .unwrap_or(&edge.callee_name);
218 let binding = import_bindings
219 .get(&edge.caller_file)
220 .and_then(|bindings| bindings.get(binding_name));
221 let Some(binding) = binding else {
222 return true;
223 };
224 if binding.external {
225 return false;
226 }
227 if let (Some(resolved_file), Some(imported_name)) = (
228 binding.resolved_file.as_ref(),
229 binding.imported_name.as_deref(),
230 ) && let Some(reexport_binding) = import_bindings
231 .get(resolved_file)
232 .and_then(|bindings| bindings.get(imported_name))
233 {
234 return !reexport_binding.external;
235 }
236 true
237 });
238}