Skip to main content

weavatrix_rust_refactor/
specifier.rs

1//! Relative import specifiers, and what moving a file does to them.
2//!
3//! Only specifiers that denote a file take part. `./core.js` and `../lib/y` are paths; Rust's
4//! `crate::core` and Java's `com.example.Core` are module names that a file move does not
5//! rewrite, and treating them as paths would corrupt them. So everything here is gated on the
6//! specifier starting with `./` or `../`.
7//!
8//! The arithmetic is done on normalised segments rather than by string surgery, because `..`
9//! that escapes the repository root and `.` segments in the middle both produce specifiers that
10//! look plausible and resolve somewhere else.
11
12/// Whether a specifier names a file rather than a module.
13#[must_use]
14pub fn is_relative(specifier: &str) -> bool {
15    specifier.starts_with("./") || specifier.starts_with("../")
16}
17
18/// Splits a repository-relative path into its directory segments.
19fn directory_of(path: &str) -> Vec<&str> {
20    let mut segments = path.split('/').collect::<Vec<_>>();
21    segments.pop();
22    segments
23}
24
25/// Resolves a relative specifier against the directory of the importing file.
26///
27/// Returns `None` when the specifier climbs above the repository root — a specifier that cannot
28/// be resolved must not be rewritten into one that can.
29#[must_use]
30pub fn resolve(importer: &str, specifier: &str) -> Option<String> {
31    let mut segments = directory_of(importer);
32    for part in specifier.split('/') {
33        match part {
34            "." | "" => {}
35            ".." => {
36                segments.pop()?;
37            }
38            other => segments.push(other),
39        }
40    }
41    Some(segments.join("/"))
42}
43
44/// The specifier an importer in `importer` needs to reach `target`, preserving any extension
45/// style the original used.
46///
47/// Always starts with `./` or `../`: a bare `core.js` is a package name in most ecosystems, so
48/// emitting one would change what the import means.
49#[must_use]
50pub fn between(importer: &str, target: &str) -> String {
51    let from = directory_of(importer);
52    let to = target.split('/').collect::<Vec<_>>();
53    let shared = from
54        .iter()
55        .zip(to.iter())
56        .take_while(|(left, right)| left == right)
57        .count();
58    let mut parts = vec![".."; from.len().saturating_sub(shared)];
59    parts.extend(to[shared..].iter().copied());
60    let joined = parts.join("/");
61    if joined.starts_with("..") {
62        joined
63    } else {
64        format!("./{joined}")
65    }
66}
67
68/// The specifier for `target` as written from a file that has moved to `moved_to`.
69///
70/// Extensionless specifiers stay extensionless: a project that omits `.js` does so deliberately
71/// and rewriting it to include one changes resolution.
72#[must_use]
73pub fn rewrite(moved_to: &str, original: &str, resolved: &str) -> String {
74    let rewritten = between(moved_to, resolved);
75    // "Did the author write an extension?" is answered on the last segment: a specifier like
76    // `../v1.2/core` has a dot in it without naming a file type.
77    let wrote_extension = original
78        .rsplit('/')
79        .next()
80        .is_some_and(|last| last.contains('.') && !last.starts_with('.'));
81    if wrote_extension || !resolved.contains('.') {
82        return rewritten;
83    }
84    // The original omitted the extension; strip the one the resolved path carries.
85    rewritten
86        .rsplit_once('.')
87        .map_or(rewritten.clone(), |(stem, _)| stem.to_owned())
88}
89
90#[cfg(test)]
91mod tests {
92    use super::{between, is_relative, resolve, rewrite};
93
94    #[test]
95    fn only_path_specifiers_are_relative() {
96        assert!(is_relative("./core.js"));
97        assert!(is_relative("../lib/y.js"));
98        assert!(!is_relative("crate::core"));
99        assert!(!is_relative("react"));
100        assert!(!is_relative("com.example.Core"));
101        assert!(!is_relative("/absolute/core.js"));
102    }
103
104    #[test]
105    fn a_specifier_resolves_against_the_importing_directory() {
106        assert_eq!(
107            resolve("src/app/main.js", "./core.js"),
108            Some("src/app/core.js".to_owned())
109        );
110        assert_eq!(
111            resolve("src/app/main.js", "../lib/y.js"),
112            Some("src/lib/y.js".to_owned())
113        );
114        assert_eq!(
115            resolve("src/app/main.js", "../../top.js"),
116            Some("top.js".to_owned())
117        );
118    }
119
120    #[test]
121    fn a_specifier_that_escapes_the_root_resolves_to_nothing() {
122        assert_eq!(resolve("main.js", "../outside.js"), None);
123    }
124
125    #[test]
126    fn the_specifier_between_two_files_is_always_explicitly_relative() {
127        // A bare name is a package in most ecosystems, so a sibling must keep its `./`.
128        assert_eq!(between("src/app/main.js", "src/app/core.js"), "./core.js");
129        assert_eq!(between("src/app/main.js", "src/lib/y.js"), "../lib/y.js");
130        assert_eq!(between("main.js", "src/deep/x.js"), "./src/deep/x.js");
131    }
132
133    #[test]
134    fn a_round_trip_through_resolve_and_between_is_stable() {
135        for (importer, specifier) in [
136            ("src/app/main.js", "./core.js"),
137            ("src/app/main.js", "../lib/y.js"),
138            ("a/b/c/d.js", "../../e.js"),
139        ] {
140            let resolved = resolve(importer, specifier).expect("resolves");
141            assert_eq!(
142                between(importer, &resolved),
143                specifier,
144                "{specifier} from {importer} did not survive the round trip"
145            );
146        }
147    }
148
149    #[test]
150    fn moving_a_file_deeper_adds_the_climb_its_imports_need() {
151        // src/main.js imported ./core.js; moved to src/deep/main.js it needs ../core.js.
152        let resolved = resolve("src/main.js", "./core.js").expect("resolves");
153        assert_eq!(
154            rewrite("src/deep/main.js", "./core.js", &resolved),
155            "../core.js"
156        );
157    }
158
159    #[test]
160    fn moving_a_file_up_removes_the_climb() {
161        let resolved = resolve("src/deep/main.js", "../core.js").expect("resolves");
162        assert_eq!(rewrite("src/main.js", "../core.js", &resolved), "./core.js");
163    }
164
165    #[test]
166    fn an_extensionless_specifier_stays_extensionless() {
167        let resolved = "src/core.js";
168        assert_eq!(rewrite("src/deep/main.js", "./core", resolved), "../core");
169    }
170
171    #[test]
172    fn an_explicit_extension_is_kept() {
173        let resolved = "src/core.js";
174        assert_eq!(
175            rewrite("src/deep/main.js", "./core.js", resolved),
176            "../core.js"
177        );
178    }
179}