From b6d6226416a9a846e5d63bc535a6660dc8454237 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:34:19 +0900 Subject: [PATCH] fix: strip query and fragment before finding matched tsconfig file --- Cargo.lock | 1 + Cargo.toml | 1 + .../tsconfig/cases/query-params/src/foo.js | 1 + .../tsconfig/cases/query-params/src/index.ts | 2 + .../cases/query-params/tsconfig.app.json | 9 ++++ .../tsconfig/cases/query-params/tsconfig.json | 6 +++ src/path.rs | 38 +++++++++++++++- src/tests/tsconfig_discovery.rs | 43 +++++++++++++++++++ src/tsconfig_resolver.rs | 3 +- 9 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 fixtures/tsconfig/cases/query-params/src/foo.js create mode 100644 fixtures/tsconfig/cases/query-params/src/index.ts create mode 100644 fixtures/tsconfig/cases/query-params/tsconfig.app.json create mode 100644 fixtures/tsconfig/cases/query-params/tsconfig.json diff --git a/Cargo.lock b/Cargo.lock index 6dde1773..5714b3a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -829,6 +829,7 @@ dependencies = [ "fast-glob", "indexmap", "json-strip-comments", + "memchr", "nodejs-built-in-modules", "once_cell", "papaya", diff --git a/Cargo.toml b/Cargo.toml index cb7084e1..1714b623 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ cfg-if = "1" compact_str = "0.9" fast-glob = "1" indexmap = { version = "2", features = ["serde"] } +memchr = "2" json-strip-comments = "3.1" nodejs-built-in-modules = "1.0.0" once_cell = "1" # Use `std::sync::OnceLock::get_or_try_init` when it is stable. diff --git a/fixtures/tsconfig/cases/query-params/src/foo.js b/fixtures/tsconfig/cases/query-params/src/foo.js new file mode 100644 index 00000000..e7134e70 --- /dev/null +++ b/fixtures/tsconfig/cases/query-params/src/foo.js @@ -0,0 +1 @@ +module.exports = "foo"; diff --git a/fixtures/tsconfig/cases/query-params/src/index.ts b/fixtures/tsconfig/cases/query-params/src/index.ts new file mode 100644 index 00000000..6bbffd27 --- /dev/null +++ b/fixtures/tsconfig/cases/query-params/src/index.ts @@ -0,0 +1,2 @@ +import foo from "@alias/foo.js"; +export default foo; diff --git a/fixtures/tsconfig/cases/query-params/tsconfig.app.json b/fixtures/tsconfig/cases/query-params/tsconfig.app.json new file mode 100644 index 00000000..58b3d74a --- /dev/null +++ b/fixtures/tsconfig/cases/query-params/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "paths": { + "@alias/*": ["./src/*"] + } + }, + "include": ["src/**/*"] +} diff --git a/fixtures/tsconfig/cases/query-params/tsconfig.json b/fixtures/tsconfig/cases/query-params/tsconfig.json new file mode 100644 index 00000000..48ea6ac7 --- /dev/null +++ b/fixtures/tsconfig/cases/query-params/tsconfig.json @@ -0,0 +1,6 @@ +{ + "include": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/src/path.rs b/src/path.rs index e56d69ae..82aae266 100644 --- a/src/path.rs +++ b/src/path.rs @@ -3,10 +3,23 @@ //! Code adapted from the following libraries //! * [path-absolutize](https://docs.rs/path-absolutize) //! * [normalize_path](https://docs.rs/normalize-path) -use std::path::{Component, Path, PathBuf}; +use std::{ + ffi::OsStr, + path::{Component, Path, PathBuf}, +}; pub const SLASH_START: &[char; 2] = &['/', '\\']; +/// Strip query parameters (`?...`) and hash fragments (`#...`) from a file path. +pub fn strip_query_and_fragment(path: &Path) -> &Path { + let bytes = path.as_os_str().as_encoded_bytes(); + let Some(end) = memchr::memchr2(b'?', b'#', bytes) else { + return path; + }; + // SAFETY: Splitting at ASCII `?` or `#` preserves valid OsStr encoding. + Path::new(unsafe { OsStr::from_encoded_bytes_unchecked(&bytes[..end]) }) +} + /// Extension trait to add path normalization to std's [`Path`]. pub trait PathUtil { /// Normalize this path without performing I/O. @@ -157,3 +170,26 @@ fn normalize_relative() { assert_eq!(Path::new("foo../../..").normalize_relative(), Path::new("..")); assert_eq!(Path::new("jest-runner-../../").normalize_relative(), Path::new("")); } + +#[test] +fn test_strip_query_and_fragment() { + assert_eq!(strip_query_and_fragment(Path::new("/src/foo.ts")), Path::new("/src/foo.ts")); + assert_eq!( + strip_query_and_fragment(Path::new("/src/foo.ts?custom=foo")), + Path::new("/src/foo.ts") + ); + assert_eq!( + strip_query_and_fragment(Path::new("/src/foo.ts#fragment")), + Path::new("/src/foo.ts") + ); + assert_eq!( + strip_query_and_fragment(Path::new("/src/foo.ts?key=val#frag")), + Path::new("/src/foo.ts") + ); + assert_eq!( + strip_query_and_fragment(Path::new("/src/foo.ts#frag?key=val")), + Path::new("/src/foo.ts") + ); + assert_eq!(strip_query_and_fragment(Path::new("")), Path::new("")); + assert_eq!(strip_query_and_fragment(Path::new("?query")), Path::new("")); +} diff --git a/src/tests/tsconfig_discovery.rs b/src/tests/tsconfig_discovery.rs index 48adb4cf..ee46ab9e 100644 --- a/src/tests/tsconfig_discovery.rs +++ b/src/tests/tsconfig_discovery.rs @@ -26,6 +26,49 @@ fn tsconfig_discovery_virtual_file_importer() { assert_eq!(resolved_path, Err(ResolveError::NotFound("random-import".into()))); } +/// When the importer path has query parameters (e.g. `file.tsx?custom=foo`), +/// auto-discovery should strip them before walking parent directories +/// and discover the correct tsconfig.json. +/// +/// Uses a fixture with project references where the root tsconfig has `include: []` +/// and a referenced tsconfig.app.json has `include: ["src/**/*"]` with path aliases. +/// Without stripping query params, `resolve_tsconfig_solution` fails to match the +/// file against the reference's include pattern, returning the wrong tsconfig. +#[test] +fn tsconfig_discovery_query_params() { + let f = super::fixture_root().join("tsconfig/cases/query-params"); + let expected_tsconfig = f.join("tsconfig.app.json"); + + let resolver = Resolver::new(ResolveOptions { + tsconfig: Some(TsconfigDiscovery::Auto), + ..ResolveOptions::default() + }); + + let clean_path = f.join("src/index.ts"); + + // Baseline — clean path discovers tsconfig.app.json (via project references) + let tsconfig = resolver.find_tsconfig(&clean_path).unwrap().unwrap(); + assert_eq!(tsconfig.path, expected_tsconfig, "baseline: should select referenced tsconfig"); + + // With query parameter — should discover the same referenced tsconfig + let path_with_query = format!("{}?custom=foo", clean_path.display()); + let tsconfig = resolver.find_tsconfig(&path_with_query).unwrap().unwrap(); + assert_eq!(tsconfig.path, expected_tsconfig, "query param: should select referenced tsconfig"); + + // With fragment — should discover the same referenced tsconfig + let path_with_fragment = format!("{}#fragment", clean_path.display()); + let tsconfig = resolver.find_tsconfig(&path_with_fragment).unwrap().unwrap(); + assert_eq!(tsconfig.path, expected_tsconfig, "fragment: should select referenced tsconfig"); + + // With both query and fragment — should discover the same referenced tsconfig + let path_with_both = format!("{}?custom=foo#fragment", clean_path.display()); + let tsconfig = resolver.find_tsconfig(&path_with_both).unwrap().unwrap(); + assert_eq!( + tsconfig.path, expected_tsconfig, + "query+fragment: should select referenced tsconfig" + ); +} + /// When a tsconfig.json exists but is not readable (e.g. permission denied), /// auto-discovery should skip it and return `Ok(None)` instead of erroring. #[test] diff --git a/src/tsconfig_resolver.rs b/src/tsconfig_resolver.rs index ec0cd3f7..77d0c159 100644 --- a/src/tsconfig_resolver.rs +++ b/src/tsconfig_resolver.rs @@ -53,7 +53,8 @@ impl ResolverGeneric { &self, path: P, ) -> Result>, ResolveError> { - let path = path.as_ref(); + // Vite plugins may append query params to real file paths (e.g. `file.tsx?custom=foo`), which are not valid filesystem path components. + let path = crate::path::strip_query_and_fragment(path.as_ref()); let cached_path = self.cache.value(path); self.find_tsconfig_tracing(&cached_path) }