1mod facts;
10mod graph;
11mod resolver;
12pub mod types;
13
14use std::collections::{BTreeMap, BTreeSet};
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::time::SystemTime;
18
19use oxc_span::SourceType;
20
21use facts::parse_file_facts;
22use graph::compute_verdicts;
23use resolver::{normalize_path, ModuleResolver};
24pub use types::{
25 DynamicImportFact, ExportFact, ExportName, FileFacts, FileId, ImportFact, ImportKind,
26 LivenessVerdict, OxcEngineError, OxcEngineResult, OxcEngineStats, OxcExportVerdict,
27 OxcFileVerdicts, OxcResolvedEdge, ReExportFact, ReExportKind, ResolverConfigInput,
28 OXC_PROVENANCE,
29};
30
31pub(crate) const FACTS_FORMAT_VERSION: u32 = 3;
32
33#[derive(Debug, Clone, Default)]
34pub struct AnalyzeOptions {
35 pub entry_points: Vec<PathBuf>,
36 pub public_api_files: Vec<PathBuf>,
37 pub force_reparse_files: Vec<PathBuf>,
41 pub entry_reachability: bool,
45}
46
47#[derive(Debug, Clone, Default)]
48pub struct OxcFactsCache {
49 entries_by_hash: BTreeMap<String, FileFacts>,
50 entries_by_path: BTreeMap<PathBuf, OxcFactsPathEntry>,
51}
52
53#[derive(Debug, Clone)]
54struct OxcFactsPathEntry {
55 mtime: SystemTime,
56 size: u64,
57 cache_key: String,
58}
59
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
61pub struct OxcFactsCacheStats {
62 pub hits: usize,
63 pub misses: usize,
64}
65
66impl OxcFactsCache {
67 pub fn new() -> Self {
68 Self::default()
69 }
70
71 pub fn len(&self) -> usize {
72 self.entries_by_hash.len()
73 }
74
75 pub fn is_empty(&self) -> bool {
76 self.entries_by_hash.is_empty()
77 }
78
79 fn facts_for_file(
80 &mut self,
81 file_id: FileId,
82 path: &Path,
83 force_reparse: bool,
84 stats: &mut OxcFactsCacheStats,
85 ) -> std::io::Result<FileFacts> {
86 let source_type = SourceType::from_path(path).unwrap_or_default();
87 let source_type_key = source_type_cache_key(source_type);
88 let metadata = fs::metadata(path)?;
89 let mtime = metadata.modified().unwrap_or(std::time::UNIX_EPOCH);
90 let size = metadata.len();
91 let path_key = path.to_path_buf();
92
93 if !force_reparse {
94 if let Some(entry) = self.entries_by_path.get(&path_key) {
95 if entry.mtime == mtime && entry.size == size {
96 if let Some(cached) = self.entries_by_hash.get(&entry.cache_key) {
97 stats.hits += 1;
98 return Ok(rebind_facts(cached, file_id, path, &cached.content_hash));
99 }
100 }
101 }
102 }
103
104 let source = fs::read_to_string(path)?;
105 Ok(self.facts_for_source_with_metadata(
106 file_id,
107 path,
108 &source,
109 source_type,
110 source_type_key,
111 Some((mtime, size)),
112 stats,
113 ))
114 }
115
116 fn facts_for_source_with_metadata(
117 &mut self,
118 file_id: FileId,
119 path: &Path,
120 source: &str,
121 source_type: SourceType,
122 source_type_key: String,
123 metadata: Option<(SystemTime, u64)>,
124 stats: &mut OxcFactsCacheStats,
125 ) -> FileFacts {
126 let content_hash = crate::cache_freshness::hash_bytes(source.as_bytes())
127 .to_hex()
128 .to_string();
129 let cache_key = format!("v{FACTS_FORMAT_VERSION}:{source_type_key}:{content_hash}");
130 if let Some(cached) = self.entries_by_hash.get(&cache_key) {
131 stats.hits += 1;
132 if let Some((mtime, size)) = metadata {
133 self.entries_by_path.insert(
134 path.to_path_buf(),
135 OxcFactsPathEntry {
136 mtime,
137 size,
138 cache_key,
139 },
140 );
141 }
142 return rebind_facts(cached, file_id, path, &content_hash);
143 }
144
145 stats.misses += 1;
146 let facts = parse_file_facts(file_id, path, source, content_hash, source_type);
147 self.entries_by_hash
148 .insert(cache_key.clone(), facts.clone());
149 if let Some((mtime, size)) = metadata {
150 self.entries_by_path.insert(
151 path.to_path_buf(),
152 OxcFactsPathEntry {
153 mtime,
154 size,
155 cache_key,
156 },
157 );
158 }
159 facts
160 }
161}
162
163fn rebind_facts(cached: &FileFacts, file_id: FileId, path: &Path, content_hash: &str) -> FileFacts {
164 let mut facts = cached.clone();
165 facts.file_id = file_id;
166 facts.path = path.to_path_buf();
167 facts.content_hash = content_hash.to_string();
168 facts
169}
170
171fn source_type_cache_key(source_type: SourceType) -> String {
172 let language = if source_type.is_typescript_definition() {
173 "dts"
174 } else if source_type.is_typescript() {
175 "ts"
176 } else {
177 "js"
178 };
179 let module_kind = if source_type.is_commonjs() {
180 "commonjs"
181 } else if source_type.is_module() {
182 "module"
183 } else if source_type.is_script() {
184 "script"
185 } else {
186 "unambiguous"
187 };
188 let variant = if source_type.is_jsx() {
189 "jsx"
190 } else {
191 "standard"
192 };
193
194 format!("{language}:{module_kind}:{variant}")
195}
196
197pub fn analyze_files(
198 project_root: &Path,
199 files: &[PathBuf],
200 options: AnalyzeOptions,
201) -> Result<OxcEngineResult, String> {
202 let mut cache = OxcFactsCache::new();
203 analyze_files_with_cache(project_root, files, options, &mut cache)
204}
205
206pub fn analyze_files_with_cache(
207 project_root: &Path,
208 files: &[PathBuf],
209 options: AnalyzeOptions,
210 cache: &mut OxcFactsCache,
211) -> Result<OxcEngineResult, String> {
212 let project_root =
213 fs::canonicalize(project_root).unwrap_or_else(|_| normalize_path(project_root));
214 let force_reparse_files = normalize_option_paths(&options.force_reparse_files);
215 let normalized_files = normalize_file_set(&project_root, files);
216 let files = normalized_files.files;
217 let skipped_outside_root = normalized_files.skipped_outside_root;
218 let mut cache_stats = OxcFactsCacheStats::default();
219 let mut errors = Vec::new();
220 let mut facts = Vec::with_capacity(files.len());
221
222 for (idx, path) in files.iter().enumerate() {
223 match cache.facts_for_file(
224 FileId(idx),
225 path,
226 force_reparse_files.contains(path),
227 &mut cache_stats,
228 ) {
229 Ok(file_facts) => facts.push(file_facts),
230 Err(error) => errors.push(OxcEngineError {
231 file: path.clone(),
232 message: format!("read: {error}"),
233 }),
234 }
235 }
236
237 Ok(analyze_preparsed_facts(
238 project_root,
239 facts,
240 options,
241 cache_stats,
242 errors,
243 skipped_outside_root,
244 ))
245}
246
247pub(crate) fn analyze_file_facts(
248 project_root: &Path,
249 facts: Vec<FileFacts>,
250 options: AnalyzeOptions,
251 skipped_outside_root: Vec<PathBuf>,
252) -> OxcEngineResult {
253 let project_root =
254 fs::canonicalize(project_root).unwrap_or_else(|_| normalize_path(project_root));
255 analyze_preparsed_facts(
256 project_root,
257 facts,
258 options,
259 OxcFactsCacheStats::default(),
260 Vec::new(),
261 skipped_outside_root,
262 )
263}
264
265fn analyze_preparsed_facts(
266 project_root: PathBuf,
267 mut facts: Vec<FileFacts>,
268 options: AnalyzeOptions,
269 cache_stats: OxcFactsCacheStats,
270 mut errors: Vec<OxcEngineError>,
271 skipped_outside_root: Vec<PathBuf>,
272) -> OxcEngineResult {
273 for (idx, fact) in facts.iter_mut().enumerate() {
276 fact.file_id = FileId(idx);
277 if let Some(parse_error) = &fact.parse_error {
278 errors.push(OxcEngineError {
279 file: fact.path.clone(),
280 message: format!("parse: {parse_error}"),
281 });
282 }
283 }
284 let resolved_files = facts
285 .iter()
286 .map(|fact| fact.path.clone())
287 .collect::<Vec<_>>();
288 let resolver = ModuleResolver::new(&project_root, &resolved_files);
289 let (resolved_modules, tracker, edges) = resolver.resolve_modules(&facts);
290 let entry_points = normalize_option_paths(&options.entry_points);
291 let public_api_files = normalize_option_paths(&options.public_api_files);
292 let file_verdicts = compute_verdicts(
293 &project_root,
294 &resolved_modules,
295 &entry_points,
296 &public_api_files,
297 options.entry_reachability,
298 );
299 let resolved_edges = edges
300 .iter()
301 .filter(|edge| edge.resolved_file.is_some())
302 .count();
303 let unresolved_edges = edges.len().saturating_sub(resolved_edges);
304 let resolver_config_inputs = tracker.inputs();
305 let resolver_config_fingerprint = tracker.fingerprint();
306
307 OxcEngineResult {
308 files: file_verdicts,
309 facts,
310 resolver_config_inputs,
311 resolver_config_fingerprint,
312 edges,
313 stats: OxcEngineStats {
314 files: resolved_files.len(),
315 cache_hits: cache_stats.hits,
316 cache_misses: cache_stats.misses,
317 resolved_edges,
318 unresolved_edges,
319 },
320 errors,
321 skipped_outside_root,
322 }
323}
324
325#[derive(Debug, Default)]
326struct NormalizedFileSet {
327 files: Vec<PathBuf>,
328 skipped_outside_root: Vec<PathBuf>,
329}
330
331fn normalize_file_set(project_root: &Path, files: &[PathBuf]) -> NormalizedFileSet {
332 let mut normalized = NormalizedFileSet::default();
333 for path in files.iter().filter(|path| is_ts_js_file(path)) {
334 let path = normalize_input_path(project_root, path);
335 if path.strip_prefix(project_root).is_ok() {
336 normalized.files.push(path);
337 } else {
338 normalized.skipped_outside_root.push(path);
339 }
340 }
341
342 normalized.files.sort();
343 normalized.files.dedup();
344 normalized.skipped_outside_root.sort();
345 normalized.skipped_outside_root.dedup();
346 normalized
347}
348
349fn normalize_input_path(project_root: &Path, path: &Path) -> PathBuf {
350 fs::canonicalize(path).unwrap_or_else(|_| {
351 if path.is_absolute() {
352 normalize_path(path)
353 } else {
354 normalize_path(&project_root.join(path))
355 }
356 })
357}
358
359fn normalize_option_paths(paths: &[PathBuf]) -> BTreeSet<PathBuf> {
360 paths
361 .iter()
362 .map(|path| fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path)))
363 .collect()
364}
365
366fn is_ts_js_file(path: &Path) -> bool {
367 path.extension()
368 .and_then(|ext| ext.to_str())
369 .is_some_and(|ext| {
370 matches!(
371 ext,
372 "ts" | "tsx" | "js" | "jsx" | "mts" | "cts" | "mjs" | "cjs"
373 )
374 })
375}