Skip to content

Commit bf08a0e

Browse files
authored
fix: normalize aliased path (#78)
* fix: normalize aliased path closes #73 closes #79 * update
1 parent dbe3a22 commit bf08a0e

File tree

9 files changed

+125
-43
lines changed

9 files changed

+125
-43
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,13 @@ jobs:
7878

7979
- name: corepack
8080
run: corepack enable
81-
81+
8282
- name: Setup Node.js
8383
uses: actions/setup-node@v4
8484
with:
8585
node-version: 20
8686
cache: pnpm
87-
87+
8888
- name: Install dependencies
8989
run: pnpm install --frozen-lockfile
9090

@@ -211,6 +211,8 @@ jobs:
211211
- os: ubuntu-latest
212212
- os: macos-14
213213
runs-on: ${{ matrix.os }}
214+
env:
215+
RUST_BACKTRACE: 1
214216
steps:
215217
- uses: actions/checkout@v4
216218
- uses: ./.github/actions/pnpm

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@ rust-version = "1.60"
1414
include = ["/src", "/examples", "/benches"]
1515

1616
[workspace]
17-
members = [
18-
"napi",
19-
]
17+
members = ["napi"]
2018

2119
[lib]
2220
doctest = false
@@ -89,9 +87,10 @@ json-strip-comments = { version = "1.0.2" }
8987
codspeed-criterion-compat = { version = "2.3.3", default-features = false, optional = true }
9088

9189
[dev-dependencies]
92-
vfs = "0.10.0" # for testing with in memory file system
93-
rayon = { version = "1.8.1" }
94-
criterion = { version = "0.5.1", default-features = false }
90+
vfs = "0.10.0" # for testing with in memory file system
91+
rayon = { version = "1.8.1" }
92+
criterion = { version = "0.5.1", default-features = false }
93+
normalize-path = { version = "0.2.1" }
9594

9695
[features]
9796
codspeed = ["codspeed-criterion-compat"]

src/lib.rs

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ impl<Fs: FileSystem + Default> ResolverGeneric<Fs> {
244244

245245
match specifier.as_bytes()[0] {
246246
// 3. If X begins with './' or '/' or '../'
247-
b'/' => self.require_absolute(cached_path, specifier, ctx),
247+
b'/' | b'\\' => self.require_absolute(cached_path, specifier, ctx),
248248
// 3. If X begins with './' or '/' or '../'
249249
b'.' => self.require_relative(cached_path, specifier, ctx),
250250
// 4. If X begins with '#'
@@ -278,7 +278,7 @@ impl<Fs: FileSystem + Default> ResolverGeneric<Fs> {
278278
specifier: &str,
279279
ctx: &mut Ctx,
280280
) -> Result<CachedPath, ResolveError> {
281-
debug_assert!(specifier.starts_with('/'));
281+
debug_assert!(specifier.starts_with(|c| c == '/' || c == '\\'));
282282
if !self.options.prefer_relative && self.options.prefer_absolute {
283283
if let Ok(path) = self.load_package_self_or_node_modules(cached_path, specifier, ctx) {
284284
return Ok(path);
@@ -295,9 +295,11 @@ impl<Fs: FileSystem + Default> ResolverGeneric<Fs> {
295295
} else {
296296
for root in &self.options.roots {
297297
let cached_path = self.cache.value(root);
298-
if let Ok(path) =
299-
self.require_relative(&cached_path, specifier.trim_start_matches('/'), ctx)
300-
{
298+
if let Ok(path) = self.require_relative(
299+
&cached_path,
300+
specifier.trim_start_matches(|c| c == '/' || c == '\\'),
301+
ctx,
302+
) {
301303
return Ok(path);
302304
}
303305
}
@@ -887,16 +889,25 @@ impl<Fs: FileSystem + Default> ResolverGeneric<Fs> {
887889
&& !request.strip_prefix(alias_value).is_some_and(|prefix| prefix.starts_with('/'))
888890
{
889891
let tail = &request[alias_key.len()..];
890-
// Must not append anything to alias_value if it is a file.
891-
if !tail.is_empty() {
892-
let alias_value_cached_path = self.cache.value(Path::new(alias_value));
892+
893+
let new_specifier = if tail.is_empty() {
894+
Cow::Borrowed(alias_value)
895+
} else {
896+
let alias_value = Path::new(alias_value).normalize();
897+
// Must not append anything to alias_value if it is a file.
898+
let alias_value_cached_path = self.cache.value(&alias_value);
893899
if alias_value_cached_path.is_file(&self.cache.fs, ctx) {
894900
return Ok(None);
895901
}
896-
}
897-
let new_specifier = format!("{alias_value}{tail}");
902+
903+
// Remove the leading slash so the final path is concatenated.
904+
let tail = tail.trim_start_matches(|c| c == '/' || c == '\\');
905+
let normalized = alias_value.normalize_with(tail);
906+
Cow::Owned(normalized.to_string_lossy().to_string())
907+
};
908+
898909
ctx.with_fully_specified(false);
899-
return match self.require(cached_path, &new_specifier, ctx) {
910+
return match self.require(cached_path, new_specifier.as_ref(), ctx) {
900911
Err(ResolveError::NotFound(_)) => Ok(None),
901912
Ok(path) => return Ok(Some(path)),
902913
Err(err) => return Err(err),

src/path.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ pub trait PathUtil {
2727
}
2828

2929
impl PathUtil for Path {
30+
// https://github.com/parcel-bundler/parcel/blob/e0b99c2a42e9109a9ecbd6f537844a1b33e7faf5/packages/utils/node-resolver-rs/src/path.rs#L7
3031
fn normalize(&self) -> PathBuf {
3132
let mut components = self.components().peekable();
3233
let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek() {
@@ -39,7 +40,7 @@ impl PathUtil for Path {
3940

4041
for component in components {
4142
match component {
42-
Component::Prefix(..) => unreachable!(),
43+
Component::Prefix(..) => unreachable!("Path {:?}", self),
4344
Component::RootDir => {
4445
ret.push(component.as_os_str());
4546
}
@@ -56,6 +57,7 @@ impl PathUtil for Path {
5657
ret
5758
}
5859

60+
// https://github.com/parcel-bundler/parcel/blob/e0b99c2a42e9109a9ecbd6f537844a1b33e7faf5/packages/utils/node-resolver-rs/src/path.rs#L37
5961
fn normalize_with<B: AsRef<Self>>(&self, subpath: B) -> PathBuf {
6062
let subpath = subpath.as_ref();
6163
let mut components = subpath.components().peekable();
@@ -66,7 +68,9 @@ impl PathUtil for Path {
6668
let mut ret = self.to_path_buf();
6769
for component in subpath.components() {
6870
match component {
69-
Component::Prefix(..) | Component::RootDir => unreachable!(),
71+
Component::Prefix(..) | Component::RootDir => {
72+
unreachable!("Path {:?} Subpath {:?}", self, subpath)
73+
}
7074
Component::CurDir => {}
7175
Component::ParentDir => {
7276
ret.pop();

src/tests/alias.rs

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
//! <https://github.com/webpack/enhanced-resolve/blob/main/test/alias.test.js>
22
3-
use crate::{AliasValue, Resolution, ResolveError, ResolveOptions, Resolver};
3+
use normalize_path::NormalizePath;
4+
use std::path::Path;
5+
6+
use crate::{AliasValue, Resolution, ResolveContext, ResolveError, ResolveOptions, Resolver};
47

58
#[test]
69
#[cfg(not(target_os = "windows"))] // MemoryFS's path separator is always `/` so the test will not pass in windows.
@@ -129,29 +132,33 @@ fn absolute_path() {
129132
assert_eq!(resolution, Err(ResolveError::Ignored(f.join("foo"))));
130133
}
131134

135+
fn check_slash(path: &Path) {
136+
let s = path.to_string_lossy().to_string();
137+
#[cfg(target_os = "windows")]
138+
{
139+
assert!(!s.contains('/'), "{s}");
140+
assert!(s.contains('\\'), "{s}");
141+
}
142+
#[cfg(not(target_os = "windows"))]
143+
{
144+
assert!(s.contains('/'), "{s}");
145+
assert!(!s.contains('\\'), "{s}");
146+
}
147+
}
148+
132149
#[test]
133150
fn system_path() {
134151
let f = super::fixture();
135152
let resolver = Resolver::new(ResolveOptions {
136153
alias: vec![(
137154
"@app".into(),
138-
vec![AliasValue::Path(f.join("alias").to_str().unwrap().to_string())],
155+
vec![AliasValue::Path(f.join("alias").to_string_lossy().to_string())],
139156
)],
140157
..ResolveOptions::default()
141158
});
142-
let resolution = resolver.resolve(&f, "@app/files/a").map(Resolution::into_path_buf);
143-
assert_eq!(resolution, Ok(f.join("alias/files/a.js")));
144-
let string = resolution.unwrap().to_string_lossy().to_string();
145-
#[cfg(target_os = "windows")]
146-
{
147-
assert!(!string.contains('/'));
148-
assert!(string.contains('\\'));
149-
}
150-
#[cfg(not(target_os = "windows"))]
151-
{
152-
assert!(string.contains('/'));
153-
assert!(!string.contains('\\'));
154-
}
159+
let path = resolver.resolve(&f, "@app/files/a").map(Resolution::into_path_buf).unwrap();
160+
assert_eq!(path, f.join("alias/files/a.js"));
161+
check_slash(&path);
155162
}
156163

157164
// Not part of enhanced-resolve
@@ -168,3 +175,43 @@ fn infinite_recursion() {
168175
let resolution = resolver.resolve(f, "./a");
169176
assert_eq!(resolution, Err(ResolveError::Recursion));
170177
}
178+
179+
#[test]
180+
fn alias_is_full_path() {
181+
let f = super::fixture();
182+
let dir = f.join("foo");
183+
let dir_str = dir.to_string_lossy().to_string();
184+
185+
let resolver = Resolver::new(ResolveOptions {
186+
alias: vec![("@".into(), vec![AliasValue::Path(dir_str.clone())])],
187+
..ResolveOptions::default()
188+
});
189+
190+
let mut ctx = ResolveContext::default();
191+
192+
let specifiers = [
193+
"@/index".to_string(),
194+
// specifier has multiple `/` for reasons we'll never know
195+
"@////index".to_string(),
196+
// specifier is a full path
197+
dir_str,
198+
];
199+
200+
for specifier in specifiers {
201+
let resolution = resolver.resolve_with_context(&f, &specifier, &mut ctx);
202+
assert_eq!(resolution.map(|r| r.full_path()), Ok(dir.join("index.js")));
203+
}
204+
205+
for path in ctx.file_dependencies {
206+
assert_eq!(path, path.normalize(), "{path:?}");
207+
check_slash(&path);
208+
}
209+
210+
for path in ctx.missing_dependencies {
211+
assert_eq!(path, path.normalize(), "{path:?}");
212+
check_slash(&path);
213+
if let Some(path) = path.parent() {
214+
assert!(!path.is_file(), "{path:?} must not be a file");
215+
}
216+
}
217+
}

src/tests/missing.rs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
//! https://github.com/webpack/enhanced-resolve/blob/main/test/missing.test.js
22
3+
use normalize_path::NormalizePath;
4+
35
use crate::{AliasValue, ResolveContext, ResolveOptions, Resolver};
46

57
#[test]
@@ -52,10 +54,15 @@ fn test() {
5254
let mut ctx = ResolveContext::default();
5355
let _ = resolver.resolve_with_context(&f, specifier, &mut ctx);
5456

55-
for dep in missing_dependencies {
57+
for path in ctx.file_dependencies {
58+
assert_eq!(path, path.normalize(), "{path:?}");
59+
}
60+
61+
for path in missing_dependencies {
62+
assert_eq!(path, path.normalize(), "{path:?}");
5663
assert!(
57-
ctx.missing_dependencies.contains(&dep),
58-
"{specifier}: {dep:?} not in {:?}",
64+
ctx.missing_dependencies.contains(&path),
65+
"{specifier}: {path:?} not in {:?}",
5966
&ctx.missing_dependencies
6067
);
6168
}
@@ -86,8 +93,13 @@ fn alias_and_extensions() {
8693
let _ = resolver.resolve_with_context(&f, "@scope-js/package-name/dir/router", &mut ctx);
8794
let _ = resolver.resolve_with_context(&f, "react-dom/client", &mut ctx);
8895

89-
for dep in ctx.missing_dependencies {
90-
if let Some(path) = dep.parent() {
96+
for path in ctx.file_dependencies {
97+
assert_eq!(path, path.normalize(), "{path:?}");
98+
}
99+
100+
for path in ctx.missing_dependencies {
101+
assert_eq!(path, path.normalize(), "{path:?}");
102+
if let Some(path) = path.parent() {
91103
assert!(!path.is_file(), "{path:?} must not be a file");
92104
}
93105
}

src/tests/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ pub fn fixture_root() -> PathBuf {
2929
}
3030

3131
pub fn fixture() -> PathBuf {
32-
fixture_root().join("enhanced_resolve/test/fixtures")
32+
fixture_root().join("enhanced_resolve").join("test").join("fixtures")
3333
}
3434

3535
#[test]

src/tests/roots.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::path::PathBuf;
55
use crate::{AliasValue, ResolveError, ResolveOptions, Resolver};
66

77
fn dirname() -> PathBuf {
8-
super::fixture_root().join("enhanced_resolve/test")
8+
super::fixture_root().join("enhanced_resolve").join("test")
99
}
1010

1111
#[test]

0 commit comments

Comments
 (0)