Skip to main content

codelens_engine/call_graph/
js_imports.rs

1use 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}