From 5ab5c8c6778d4294bf864be5be908aea8a29b578 Mon Sep 17 00:00:00 2001 From: Clarence <36807519+Clarence404@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:48:31 +0800 Subject: [PATCH] fix(run): read JetBrains path options written as file: URLs (#2287) IDEA writes path options as URLs (`WORKING_DIRECTORY = file://$PROJECT_DIR$`). The run configuration importer expanded the macros but never stripped the `file:` scheme, so on Windows `file://C:/project` became the drive-relative `C:project` and was joined onto the project directory, and imported configurations failed to launch. Every path-valued option the importer reads (working directories, SDK home, script and settings files, pytest targets, env files including EnvFile entries, package.json, package manager, node interpreter, config file, test directory/file, JS file, interpreter path, path arguments) now goes through one conversion: drive letters in the authority position, `localhost` as a local authority, UNC for a real host, and IDEA's unencoded paths kept verbatim. Env-file lists no longer split at a URL's scheme colon on Unix. Co-authored-by: Clarence404 <36807519+Clarence404@users.noreply.github.com> --- crates/ide-run/src/import/jetbrains.rs | 169 +++++++++++++++-- crates/ide-run/tests/import_jetbrains.rs | 220 +++++++++++++++++++++++ 2 files changed, 371 insertions(+), 18 deletions(-) diff --git a/crates/ide-run/src/import/jetbrains.rs b/crates/ide-run/src/import/jetbrains.rs index 57ebe2c12..327ce6399 100644 --- a/crates/ide-run/src/import/jetbrains.rs +++ b/crates/ide-run/src/import/jetbrains.rs @@ -271,6 +271,11 @@ impl Cx<'_> { } } + /// A filesystem path: expand IDEA's macros before converting a file URL. + fn path(&self, value: Option<&str>) -> Res> { + Ok(self.text(value)?.map(|v| file_url_path(&v))) + } + /// Words of a parameters field, split like IDEA's `ParametersListUtil.parse`. fn words(&self, value: Option<&str>) -> Res> { self.words_quoted(value, Quotes::Double) @@ -286,12 +291,16 @@ impl Cx<'_> { } fn args_quoted(&self, value: Option<&str>, quotes: Quotes, dir: &Path) -> Res> { - Ok(self.words_quoted(value, quotes)?.into_iter().map(|a| path_from(self.root, a, dir)).collect()) + Ok(self + .words_quoted(value, quotes)? + .into_iter() + .map(|a| path_from(self.root, file_url_path(&a), dir)) + .collect()) } /// The working directory (absolute) and the configuration's `cwd`. fn cwd(&self, value: Option<&str>) -> Res<(PathBuf, Option)> { - Ok(match self.text(value)? { + Ok(match self.path(value)? { Some(dir) => working_dir(self.root, &dir), None => (self.root.to_path_buf(), None), }) @@ -350,7 +359,7 @@ impl Cx<'_> { /// options, and the working directory. fn interpreter(&self, c: &El, ignored: &mut Vec) -> Res<(String, Vec, PathBuf, Option)> { let (dir, cwd) = self.cwd(c.option("WORKING_DIRECTORY"))?; - let sdk = if c.flag("IS_MODULE_SDK") { None } else { self.text(c.option("SDK_HOME"))? }; + let sdk = if c.flag("IS_MODULE_SDK") { None } else { self.path(c.option("SDK_HOME"))? }; let (exe, mut args) = match sdk { Some(sdk) => (path_from(self.root, sdk, &dir), Vec::new()), None => project_python(self.root, &dir), @@ -369,7 +378,7 @@ impl Cx<'_> { rc.extras.include_system_env = c.option("PARENT_ENVS") != Some("false"); // PyCharm 2024+: "Paths to .env files", joined with `File.pathSeparator`. for file in split_env_files(c.option("ENV_FILES").unwrap_or_default(), PATH_SEPARATOR) { - if let Some(file) = self.text(Some(file))? { + if let Some(file) = self.path(Some(file))? { rc.extras.env_files.push(path_from(self.root, file, self.root)); } } @@ -403,7 +412,7 @@ impl Cx<'_> { ignored.push(format!("EnvFile {path}")); continue; } - if let Some(file) = self.text(Some(path))? { + if let Some(file) = self.path(Some(path))? { rc.extras.env_files.push(path_from(self.root, file, self.root)); } } @@ -418,7 +427,7 @@ impl Cx<'_> { if c.flag("MODULE_MODE") { args.extend(["-m".to_owned(), script]); } else { - args.push(path_from(self.root, script, &dir)); + args.push(path_from(self.root, file_url_path(&script), &dir)); } args.extend(self.args(c.option("PARAMETERS"), &dir)?); let mut rc = config(name, "python", exe, args, Some("debugpy")); @@ -480,7 +489,7 @@ impl Cx<'_> { args.extend([manage, "test".to_owned()]); args.extend(self.words(c.option("TARGET"))?); if c.flag("CUSTOM_SETTINGS") - && let Some(settings) = self.text(c.option("SETTINGS_FILE"))? + && let Some(settings) = self.path(c.option("SETTINGS_FILE"))? { args.extend(["--settings".to_owned(), settings_module(&settings, self.root, &dir)]); } @@ -519,7 +528,7 @@ impl Cx<'_> { } } Some("CUSTOM") => {} - _ => args.push(path_from(self.root, target, &dir)), + _ => args.push(path_from(self.root, file_url_path(&target), &dir)), } } if target_type.as_deref() != Some("CUSTOM") @@ -574,7 +583,7 @@ impl Cx<'_> { fn node_interpreter(&self, c: &El) -> Res> { match c.value("node-interpreter").or_else(|| c.attr("node-interpreter")).map(str::trim) { None | Some("" | "project") => Ok(None), - interpreter => self.text(interpreter), + interpreter => self.path(interpreter), } } @@ -587,12 +596,12 @@ impl Cx<'_> { } fn npm(&self, name: &str, c: &El, ignored: &mut Vec) -> Res { - let package_json = self.text(c.value("package-json"))?; + let package_json = self.path(c.value("package-json"))?; let dir = match &package_json { Some(p) => normalize(&self.root.join(p)).parent().map(Path::to_path_buf).unwrap_or(self.root.to_path_buf()), None => self.root.to_path_buf(), }; - let manager = match self.text(c.value("package-manager"))? { + let manager = match self.path(c.value("package-manager"))? { Some(pm) if ["npm", "pnpm", "yarn", "bun"].contains(&command_name(&pm)) => command_name(&pm).to_owned(), _ => { let pkg = std::fs::read_to_string(dir.join("package.json")) @@ -650,14 +659,14 @@ impl Cx<'_> { fn js_tests(&self, name: &str, c: &El, vitest: bool, ignored: &mut Vec) -> Res { let (dir, cwd) = self.cwd(c.value("working-dir"))?; let mut args: Vec = if vitest { vec!["vitest".into(), "run".into()] } else { vec!["jest".into()] }; - if !vitest && let Some(config_file) = self.text(c.value("config-file"))? { + if !vitest && let Some(config_file) = self.path(c.value("config-file"))? { args.extend(["--config".to_owned(), path_from(self.root, config_file, &dir)]); } args.extend(self.args(c.value(if vitest { "vitest-options" } else { "jest-options" }), &dir)?); match c.value("scope-kind").unwrap_or("ALL") { - "DIRECTORY" => args.extend(self.text(c.value("test-directory"))?.map(|d| path_from(self.root, d, &dir))), + "DIRECTORY" => args.extend(self.path(c.value("test-directory"))?.map(|d| path_from(self.root, d, &dir))), "TEST_FILE" | "SUITE" | "TEST" => { - args.extend(self.text(c.value("test-file"))?.map(|f| path_from(self.root, f, &dir))); + args.extend(self.path(c.value("test-file"))?.map(|f| path_from(self.root, f, &dir))); let names: Vec<&str> = c .child("test-names") .into_iter() @@ -686,7 +695,7 @@ impl Cx<'_> { fn node(&self, name: &str, c: &El, ignored: &mut Vec) -> Res { let (dir, cwd) = self.cwd(c.attr("working-dir"))?; - let Some(file) = self.text(c.attr("path-to-js-file"))? else { + let Some(file) = self.path(c.attr("path-to-js-file"))? else { return Err(SkipReason::NothingToRun); }; let mut args = self.words(c.attr("node-parameters"))?; @@ -756,11 +765,11 @@ impl Cx<'_> { let mut params = BTreeMap::new(); // Both default to true when missing (`ShRunConfiguration.java:148-149`). let rc = if c.option("EXECUTE_SCRIPT_FILE") != Some("false") { - let Some(path) = self.text(c.option("SCRIPT_PATH"))? else { return Err(SkipReason::NothingToRun) }; + let Some(path) = self.path(c.option("SCRIPT_PATH"))? else { return Err(SkipReason::NothingToRun) }; let path = path_from(self.root, path, &dir); let options = self.text(c.option("SCRIPT_OPTIONS"))?.unwrap_or_default(); let interpreter = - self.text(c.option("INTERPRETER_PATH"))?.map(|i| path_from(self.root, i, &dir)).unwrap_or_default(); + self.path(c.option("INTERPRETER_PATH"))?.map(|i| path_from(self.root, i, &dir)).unwrap_or_default(); let interpreter_options = self.text(c.option("INTERPRETER_OPTIONS"))?.unwrap_or_default(); // Run directly, the script is the program: `./run.sh`, so it isn't looked up on the PATH. let path = if interpreter.is_empty() { dot_slash(path) } else { path }; @@ -861,7 +870,67 @@ const PATH_SEPARATOR: char = if cfg!(windows) { ';' } else { ':' }; /// The `ENV_FILES` paths, joined with `separator` (intellij-community /// `AbstractPythonRunConfiguration.java:303-304`, `:337`). fn split_env_files(value: &str, separator: char) -> impl Iterator { - value.split(separator).filter(|f| !f.trim().is_empty()) + let mut rest = value; + std::iter::from_fn(move || { + if rest.is_empty() { + return None; + } + let end = rest.match_indices(separator).find_map(|(end, _)| { + let field = rest[..end].trim_start(); + if separator == ':' { + // A URL's scheme and optional drive letter aren't list separators. + let scheme = field.eq_ignore_ascii_case("file"); + let drive = field.strip_prefix("file:").is_some_and(|body| { + let body = body.trim_start_matches('/'); + let body = body + .split_once('/') + .filter(|(host, _)| host.eq_ignore_ascii_case("localhost")) + .map_or(body, |(_, path)| path); + body.len() == 1 && body.as_bytes()[0].is_ascii_alphabetic() + }); + if scheme || drive { + return None; + } + } + Some(end) + }); + let field = match end { + Some(end) => { + let field = &rest[..end]; + rest = &rest[end + separator.len_utf8()..]; + field + } + None => std::mem::take(&mut rest), + }; + Some(field) + }) + .filter(|f| !f.trim().is_empty()) +} + +/// The path a `file:` URL names, and `value` unchanged when it is not one. +/// +/// IDEA writes path options as URLs (`file://$PROJECT_DIR$`), which expand to +/// `file://C:/project` on Windows: the drive letter lands where a URL's authority +/// belongs. Parsing that as a path yields `C:project`, a drive-relative path that +/// then resolves against the project directory instead of replacing it. +fn file_url_path(value: &str) -> String { + let Some(rest) = value.strip_prefix("file:") else { + return value.to_owned(); + }; + let rest = match rest.strip_prefix("//").and_then(|r| r.split_once('/')) { + Some((host, path)) if host.eq_ignore_ascii_case("localhost") => path, + _ => rest, + }; + let body = rest.trim_start_matches('/'); + let drive = matches!(body.as_bytes(), [d, b':', ..] if d.is_ascii_alphabetic()); + if drive { + body.to_owned() + } else if rest.starts_with("//") && !rest.starts_with("///") { + // A real authority (`file://server/share`): a UNC path. + format!("//{body}") + } else { + format!("/{body}") + } } /// Keywords without the quotes around them (`"not slow"` → `not slow`). @@ -965,6 +1034,57 @@ mod tests { assert!(parse("").is_err()); } + #[test] + fn file_urls_become_paths_whatever_the_drive_letter_looks_like() { + // IDEA on Windows: the drive letter sits where the authority belongs. + assert_eq!(file_url_path("file://C:/p"), "C:/p"); + assert_eq!(file_url_path(r"file://C:\p"), r"C:\p"); + assert_eq!(file_url_path("file:///C:/p"), "C:/p"); + assert_eq!(file_url_path("file:///p/a"), "/p/a"); + assert_eq!(file_url_path("file://server/share"), "//server/share"); + // Not a URL: left alone, including a bare Windows path. + assert_eq!(file_url_path(r"C:\p"), r"C:\p"); + assert_eq!(file_url_path("/p/a"), "/p/a"); + assert_eq!(file_url_path(""), ""); + } + + #[test] + fn file_urls_preserve_paths_and_treat_localhost_as_local() { + for (url, expected) in [ + ("file:///p/100%20", "/p/100%20"), + ("file:///p/a%E4", "/p/a%E4"), + ("file:///p/a b/中.py", "/p/a b/中.py"), + ("file://C:/a b/é", "C:/a b/é"), + ("file:///C:/a%20b", "C:/a%20b"), + ("file://localhost/p/a%20b", "/p/a%20b"), + ("file://localhost/C:/a%20b", "C:/a%20b"), + ("file://LOCALHOST/p", "/p"), + ("file://server/share/a%20b", "//server/share/a%20b"), + ("file:///p/%2520", "/p/%2520"), + ("/p/a%20b", "/p/a%20b"), + ("relative%20path", "relative%20path"), + ("https://localhost/a%20b", "https://localhost/a%20b"), + ] { + assert_eq!(file_url_path(url), expected, "{url}"); + } + } + + /// The property the bug broke: `working_dir` joins its `cwd` onto the project + /// directory, so a URL that reads as a relative path resolves inside it instead + /// of replacing it. + #[test] + fn file_urls_name_absolute_paths() { + let absolute = if cfg!(windows) { + vec!["file://C:/p", "file:///C:/p", r"file://C:\p", "file://server/share"] + } else { + vec!["file:///p/a", "file://server/share"] + }; + for url in absolute { + let path = file_url_path(url); + assert!(Path::new(&path).is_absolute(), "{url} became {path:?}, which is not absolute"); + } + } + #[test] fn env_files_split_on_the_path_separator() { assert_eq!( @@ -975,6 +1095,19 @@ mod tests { split_env_files(r"C:\p\.env;C:\p\.env.local", ';').collect::>(), [r"C:\p\.env", r"C:\p\.env.local"] ); + assert_eq!( + split_env_files("file://$PROJECT_DIR$/.env:file://localhost/p/a%20b:.env.local", ':').collect::>(), + ["file://$PROJECT_DIR$/.env", "file://localhost/p/a%20b", ".env.local"] + ); + assert_eq!( + split_env_files("file://C:/p/.env:file:///D:/p/.env:file://localhost/E:/p/.env%3Adev", ':') + .collect::>(), + ["file://C:/p/.env", "file:///D:/p/.env", "file://localhost/E:/p/.env%3Adev"] + ); + assert_eq!( + split_env_files("file://C:/p/.env;file://localhost/C:/p/.env.local", ';').collect::>(), + ["file://C:/p/.env", "file://localhost/C:/p/.env.local"] + ); assert_eq!(split_env_files("", ':').count(), 0); assert_eq!(PATH_SEPARATOR, if cfg!(windows) { ';' } else { ':' }); } diff --git a/crates/ide-run/tests/import_jetbrains.rs b/crates/ide-run/tests/import_jetbrains.rs index d50d714df..97473927b 100644 --- a/crates/ide-run/tests/import_jetbrains.rs +++ b/crates/ide-run/tests/import_jetbrains.rs @@ -787,3 +787,223 @@ fn java_applications_and_spring_boot_keep_their_module() { assert_eq!(reason(&r, "NoMain"), &SkipReason::NothingToRun); } + +/// IDEA writes the working directory as a URL. On Windows `file://$PROJECT_DIR$` +/// expands to `file://C:/project`, where the drive letter sits where the authority +/// belongs; read as a path that is `C:project`, which resolves against the project +/// directory instead of replacing it and the launch fails on the doubled path. +#[test] +fn file_url_working_directories_stay_inside_the_project() { + let dir = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(dir.path()).unwrap(); + write(&root, "pom.xml", "shoppom"); + write( + &root, + "gateway/pom.xml", + "shopgateway", + ); + write( + &root, + ".run/Gateway.run.xml", + &run_file( + "Gateway", + r#" + + "#, + ), + ); + write( + &root, + ".run/Sub.run.xml", + &run_file( + "Sub", + r#" + + "#, + ), + ); + + let local_root = root.to_string_lossy().replace('\\', "/"); + for (name, working_directory) in [ + ("Literal", "file://$PROJECT_DIR$/gateway é".to_owned()), + ("Localhost", format!("file://localhost/{}", local_root.trim_start_matches('/'))), + ] { + write( + &root, + &format!(".run/{name}.run.xml"), + &run_file( + name, + &format!( + r#" + + + "# + ), + ), + ); + } + let r = import_from(&root, &[]); + let boot = get(&r, "Gateway"); + assert_eq!(boot.kind, "spring-boot"); + assert_eq!(boot.extras.module.as_deref(), Some("gateway")); + assert_eq!( + boot.args, + s(&[ + "-Xmx512m", + "-cp", + "${classpath}", + "com.example.GatewayApplication", + "--spring.profiles.active=dev,local", + "--server.port=8081" + ]) + ); + assert_eq!(boot.env["TZ"], "UTC"); + assert_eq!(cwd(get(&r, "Literal")), Some(PathBuf::from("gateway é"))); + assert_eq!(cwd(get(&r, "Localhost")), None); + + // The project directory itself: no `cwd` to store. + assert_eq!(cwd(get(&r, "Gateway")), None); + assert_eq!(cwd(get(&r, "Sub")), Some(PathBuf::from("gateway"))); +} + +#[test] +fn file_urls_work_in_all_imported_path_fields() { + let dir = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(dir.path()).unwrap(); + let cases = [ + ( + "Python", + r#" + "#, + ), + ( + "Django", + r#" + "#, + ), + ( + "Pytest", + r#" + "#, + ), + ( + "Npm", + r#" + + + +