Skip to main content

hearth_graph/resolve/
rust.rs

1//! Rust module-tree resolution.
2//!
3//! v1 does not reproduce the module declaration tree, including multi-level
4//! inline modules, `#[path]`, `cfg`, macros, and `mod` ambiguity. To keep the
5//! label constitutively sound, every outcome is `Partial`. Making Rust
6//! resolution exact requires Cargo target metadata plus a declaration-tree
7//! model, which is future work.
8
9use std::{
10    cmp::Ordering,
11    fs, io,
12    path::{Path, PathBuf},
13};
14
15use compact_str::CompactString;
16
17use super::{
18    FailedKind, ResolutionCompleteness, ResolutionOutcome, Resolve, Resolved, UnresolvedReason,
19};
20use crate::imports::{ImportKind, RawImport};
21
22const COMPLETENESS: ResolutionCompleteness = ResolutionCompleteness::Partial;
23
24/// Configuration for Rust module-tree resolution.
25#[derive(Debug, Clone)]
26pub struct RustResolveOptions {
27    /// Absolute paths to crate-root files such as `src/lib.rs` and `src/main.rs`.
28    pub crate_roots: Vec<CompactString>,
29}
30
31/// Build a Rust resolver backed by the operating system filesystem.
32pub fn rust_resolver(options: RustResolveOptions) -> Box<dyn Resolve> {
33    Box::new(RustResolver { options })
34}
35
36struct RustResolver {
37    options: RustResolveOptions,
38}
39
40impl Resolve for RustResolver {
41    fn baseline_completeness(&self) -> ResolutionCompleteness {
42        COMPLETENESS
43    }
44
45    fn resolve(&self, from_file: &str, import: &RawImport) -> ResolutionOutcome {
46        let from_path = Path::new(from_file);
47        debug_assert!(
48            from_path.is_absolute(),
49            "from_file must be absolute: {from_file}"
50        );
51        if !from_path.is_absolute() {
52            return unresolved(
53                failed(FailedKind::InvalidSpecifier, "from_file must be absolute"),
54                Vec::new(),
55            );
56        }
57        let Some(_) = from_path.parent() else {
58            return unresolved(
59                failed(
60                    FailedKind::InvalidSpecifier,
61                    "from_file must have a parent directory",
62                ),
63                Vec::new(),
64            );
65        };
66
67        match import.kind {
68            ImportKind::RustMod => self.resolve_mod(from_path, import.specifier.as_str()),
69            ImportKind::RustUse => self.resolve_use(from_path, import.specifier.as_str()),
70            _ => unresolved(UnresolvedReason::Unsupported, Vec::new()),
71        }
72    }
73
74    fn clear_cache(&self) {}
75}
76
77impl RustResolver {
78    fn resolve_use(&self, from_path: &Path, specifier: &str) -> ResolutionOutcome {
79        let segments: Vec<&str> = specifier.split("::").collect();
80        let Some(first) = segments.first().copied() else {
81            return unresolved(UnresolvedReason::NotFound, Vec::new());
82        };
83
84        match first {
85            "crate" => self.resolve_from_crate_root(from_path, &segments[1..]),
86            "self" => {
87                let Some(base) = children_directory(from_path, self.is_crate_root_file(from_path))
88                else {
89                    return invalid_from_file();
90                };
91                resolve_segments(base, &segments[1..], Some(from_path.to_path_buf()))
92            }
93            "super" => self.resolve_super(from_path, &segments),
94            external => self.resolve_bare(from_path, external, &segments[1..]),
95        }
96    }
97
98    fn resolve_mod(&self, from_file: &Path, specifier: &str) -> ResolutionOutcome {
99        let Some(base) = children_directory(from_file, self.is_crate_root_file(from_file)) else {
100            return invalid_from_file();
101        };
102        let mut dependencies = Vec::new();
103        match probe_module(&base, specifier, &mut dependencies, false) {
104            Ok(Some(found)) => resolved_path(found, dependencies),
105            Ok(None) => unresolved(UnresolvedReason::NotFound, dependencies),
106            Err(error) => io_failure(error, dependencies),
107        }
108    }
109
110    fn resolve_from_crate_root(&self, from_file: &Path, segments: &[&str]) -> ResolutionOutcome {
111        let Some(root) = self.select_crate_root(from_file) else {
112            return unresolved(UnresolvedReason::NotFound, Vec::new());
113        };
114        let Some(root_dir) = root.parent() else {
115            return invalid_from_file();
116        };
117        resolve_segments(root_dir.to_path_buf(), segments, Some(root))
118    }
119
120    fn resolve_bare(&self, from_file: &Path, first: &str, remaining: &[&str]) -> ResolutionOutcome {
121        let Some(base) = children_directory(from_file, self.is_crate_root_file(from_file)) else {
122            return invalid_from_file();
123        };
124        let mut dependencies = Vec::new();
125        match probe_module(&base, first, &mut dependencies, true) {
126            Ok(Some(found)) => resolve_segments_with_dependencies(
127                base.join(first),
128                remaining,
129                Some(found),
130                dependencies,
131            ),
132            Ok(None) => ResolutionOutcome {
133                resolved: Resolved::External(first.into()),
134                dependencies,
135                notes: Vec::new(),
136                completeness: COMPLETENESS,
137            },
138            Err(error) => io_failure(error, dependencies),
139        }
140    }
141
142    fn resolve_super(&self, from_file: &Path, segments: &[&str]) -> ResolutionOutcome {
143        if self.is_crate_root_file(from_file) {
144            return unresolved(UnresolvedReason::NotFound, Vec::new());
145        }
146
147        let crate_root = self.select_crate_root(from_file);
148        let root_directory = crate_root
149            .as_deref()
150            .and_then(Path::parent)
151            .map(Path::to_path_buf);
152        let Some(mut logical_directory) = children_directory(from_file, false) else {
153            return invalid_from_file();
154        };
155        let super_count = segments
156            .iter()
157            .take_while(|segment| **segment == "super")
158            .count();
159        let mut dependencies = Vec::new();
160        let mut seed = None;
161
162        for _ in 0..super_count {
163            if root_directory
164                .as_deref()
165                .is_some_and(|root| logical_directory == root)
166                || is_standard_source_root(&logical_directory)
167            {
168                return unresolved(UnresolvedReason::NotFound, dependencies);
169            }
170            let Some(ancestor_directory) = logical_directory.parent().map(Path::to_path_buf) else {
171                return unresolved(UnresolvedReason::NotFound, dependencies);
172            };
173            if root_directory
174                .as_deref()
175                .is_some_and(|root| !ancestor_directory.starts_with(root))
176            {
177                return unresolved(UnresolvedReason::NotFound, dependencies);
178            }
179
180            seed = match ancestor_module_file(
181                &ancestor_directory,
182                crate_root.as_deref(),
183                &mut dependencies,
184            ) {
185                Ok(seed) => seed,
186                Err(error) => {
187                    return io_failure(error, dependencies);
188                }
189            };
190            logical_directory = ancestor_directory;
191        }
192
193        resolve_segments_with_dependencies(
194            logical_directory,
195            &segments[super_count..],
196            seed,
197            dependencies,
198        )
199    }
200
201    fn select_crate_root(&self, from_file: &Path) -> Option<PathBuf> {
202        if is_implicit_crate_root(from_file) {
203            return Some(from_file.to_path_buf());
204        }
205        let from_dir = from_file.parent()?;
206        let mut roots: Vec<PathBuf> = self
207            .options
208            .crate_roots
209            .iter()
210            .map(|root| PathBuf::from(root.as_str()))
211            .filter(|root| {
212                root.parent()
213                    .is_some_and(|root_dir| from_dir.starts_with(root_dir))
214            })
215            .collect();
216        roots.sort_unstable_by(|left, right| compare_crate_roots(left, right));
217        roots.dedup();
218        roots.into_iter().next()
219    }
220
221    fn is_crate_root_file(&self, path: &Path) -> bool {
222        is_implicit_crate_root(path)
223            || matches!(
224                path.file_name().and_then(|name| name.to_str()),
225                Some("lib.rs" | "main.rs")
226            )
227            || self
228                .options
229                .crate_roots
230                .iter()
231                .any(|root| Path::new(root.as_str()) == path)
232    }
233}
234
235fn resolve_segments(base: PathBuf, segments: &[&str], seed: Option<PathBuf>) -> ResolutionOutcome {
236    resolve_segments_with_dependencies(base, segments, seed, Vec::new())
237}
238
239fn resolve_segments_with_dependencies(
240    base: PathBuf,
241    segments: &[&str],
242    seed: Option<PathBuf>,
243    mut dependencies: Vec<CompactString>,
244) -> ResolutionOutcome {
245    match walk_segments(base, segments, seed, &mut dependencies) {
246        Ok(Some(found)) => resolved_path(found, dependencies),
247        Ok(None) => unresolved(UnresolvedReason::NotFound, dependencies),
248        Err(error) => io_failure(error, dependencies),
249    }
250}
251
252fn walk_segments(
253    mut directory: PathBuf,
254    segments: &[&str],
255    seed: Option<PathBuf>,
256    dependencies: &mut Vec<CompactString>,
257) -> Result<Option<PathBuf>, CandidateIoError> {
258    let mut last_found = seed;
259    for segment in segments {
260        let file_candidate = directory.join(format!("{segment}.rs"));
261        dependencies.push(compact_path(&file_candidate));
262        if candidate_is_file(&file_candidate)? {
263            last_found = Some(file_candidate);
264            directory.push(segment);
265            continue;
266        }
267
268        let module_candidate = directory.join(segment).join("mod.rs");
269        dependencies.push(compact_path(&module_candidate));
270        if candidate_is_file(&module_candidate)? {
271            last_found = Some(module_candidate);
272            directory.push(segment);
273            continue;
274        }
275        break;
276    }
277    Ok(last_found)
278}
279
280fn probe_module(
281    directory: &Path,
282    segment: &str,
283    dependencies: &mut Vec<CompactString>,
284    track_both_candidates: bool,
285) -> Result<Option<PathBuf>, CandidateIoError> {
286    let file_candidate = directory.join(format!("{segment}.rs"));
287    let module_candidate = directory.join(segment).join("mod.rs");
288    dependencies.push(compact_path(&file_candidate));
289    if track_both_candidates {
290        dependencies.push(compact_path(&module_candidate));
291    }
292    if candidate_is_file(&file_candidate)? {
293        return Ok(Some(file_candidate));
294    }
295    if !track_both_candidates {
296        dependencies.push(compact_path(&module_candidate));
297    }
298    candidate_is_file(&module_candidate).map(|found| found.then_some(module_candidate))
299}
300
301fn ancestor_module_file(
302    directory: &Path,
303    crate_root: Option<&Path>,
304    dependencies: &mut Vec<CompactString>,
305) -> Result<Option<PathBuf>, CandidateIoError> {
306    if let Some(root) = crate_root
307        && root.parent() == Some(directory)
308    {
309        return Ok(Some(root.to_path_buf()));
310    }
311
312    if is_standard_source_root(directory) {
313        for name in ["lib.rs", "main.rs"] {
314            let candidate = directory.join(name);
315            dependencies.push(compact_path(&candidate));
316            if candidate_is_file(&candidate)? {
317                return Ok(Some(candidate));
318            }
319        }
320        return Ok(None);
321    }
322
323    let file_candidate = directory.with_extension("rs");
324    dependencies.push(compact_path(&file_candidate));
325    if candidate_is_file(&file_candidate)? {
326        return Ok(Some(file_candidate));
327    }
328    let module_candidate = directory.join("mod.rs");
329    dependencies.push(compact_path(&module_candidate));
330    candidate_is_file(&module_candidate).map(|found| found.then_some(module_candidate))
331}
332
333fn children_directory(from_file: &Path, crate_root: bool) -> Option<PathBuf> {
334    let parent = from_file.parent()?;
335    if crate_root
336        || matches!(
337            from_file.file_name().and_then(|name| name.to_str()),
338            Some("mod.rs" | "lib.rs" | "main.rs")
339        )
340    {
341        Some(parent.to_path_buf())
342    } else {
343        Some(parent.join(from_file.file_stem()?))
344    }
345}
346
347fn is_implicit_crate_root(path: &Path) -> bool {
348    if path.extension().and_then(|extension| extension.to_str()) != Some("rs") {
349        return false;
350    }
351    let Some(parent) = path.parent() else {
352        return false;
353    };
354    match parent.file_name().and_then(|name| name.to_str()) {
355        Some("examples" | "tests") => true,
356        Some("bin") => parent
357            .parent()
358            .and_then(Path::file_name)
359            .is_some_and(|name| name == "src"),
360        _ => false,
361    }
362}
363
364fn is_standard_source_root(path: &Path) -> bool {
365    path.file_name().is_some_and(|name| name == "src")
366}
367
368fn compare_crate_roots(left: &Path, right: &Path) -> Ordering {
369    let left_dir = left.parent().expect("filtered crate root has a parent");
370    let right_dir = right.parent().expect("filtered crate root has a parent");
371    right_dir
372        .components()
373        .count()
374        .cmp(&left_dir.components().count())
375        .then_with(|| crate_root_priority(left).cmp(&crate_root_priority(right)))
376        .then_with(|| left.cmp(right))
377}
378
379fn crate_root_priority(path: &Path) -> u8 {
380    match path.file_name().and_then(|name| name.to_str()) {
381        Some("lib.rs") => 0,
382        Some("main.rs") => 1,
383        _ => 2,
384    }
385}
386
387fn is_file(path: &Path) -> io::Result<bool> {
388    match fs::metadata(path) {
389        Ok(metadata) => Ok(metadata.is_file()),
390        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
391        Err(error) => Err(error),
392    }
393}
394
395fn candidate_is_file(path: &Path) -> Result<bool, CandidateIoError> {
396    is_file(path).map_err(|source| CandidateIoError {
397        path: path.to_path_buf(),
398        source,
399    })
400}
401
402fn resolved_path(path: PathBuf, dependencies: Vec<CompactString>) -> ResolutionOutcome {
403    ResolutionOutcome {
404        resolved: Resolved::Path(compact_path(&path)),
405        dependencies,
406        notes: Vec::new(),
407        completeness: COMPLETENESS,
408    }
409}
410
411fn unresolved(reason: UnresolvedReason, dependencies: Vec<CompactString>) -> ResolutionOutcome {
412    ResolutionOutcome {
413        resolved: Resolved::Unresolved(reason),
414        dependencies,
415        notes: Vec::new(),
416        completeness: COMPLETENESS,
417    }
418}
419
420fn invalid_from_file() -> ResolutionOutcome {
421    unresolved(
422        failed(
423            FailedKind::InvalidSpecifier,
424            "from_file must have a file name and parent directory",
425        ),
426        Vec::new(),
427    )
428}
429
430fn io_failure(error: CandidateIoError, dependencies: Vec<CompactString>) -> ResolutionOutcome {
431    unresolved(
432        failed(
433            FailedKind::Io,
434            format!(
435                "could not inspect {}: {}",
436                error.path.display(),
437                error.source
438            ),
439        ),
440        dependencies,
441    )
442}
443
444fn failed(kind: FailedKind, detail: impl Into<CompactString>) -> UnresolvedReason {
445    UnresolvedReason::Failed {
446        kind,
447        detail: detail.into(),
448    }
449}
450
451fn compact_path(path: &Path) -> CompactString {
452    CompactString::from(path.to_string_lossy().as_ref())
453}
454
455struct CandidateIoError {
456    path: PathBuf,
457    source: io::Error,
458}