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 <[email protected]>
Цей коміт міститься в:
Clarence authored and GitHub committed 2026-11-09 15:48:31 +08:00
1 джерело 14aa612038
коміт 5ab5c8c677
2 files changed
+371 -18

No files matched your search

+151 -18
Переглянути файл
@@ -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<Option<String>> {
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<Vec<String>> {
self.words_quoted(value, Quotes::Double)
@@ -286,12 +291,16 @@ impl Cx<'_> {
}
fn args_quoted(&self, value: Option<&str>, quotes: Quotes, dir: &Path) -> Res<Vec<String>> {
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<PathBuf>)> {
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<String>) -> Res<(String, Vec<String>, PathBuf, Option<PathBuf>)> {
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<Option<String>> {
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<String>) -> Res<RunConfig> {
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<String>) -> Res<RunConfig> {
let (dir, cwd) = self.cwd(c.value("working-dir"))?;
let mut args: Vec<String> = 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<String>) -> Res<RunConfig> {
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<Item = &str> {
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("<a><b></a>").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::<Vec<_>>(),
[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::<Vec<_>>(),
["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::<Vec<_>>(),
["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::<Vec<_>>(),
["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 { ':' });
}
+220
Переглянути файл
@@ -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", "<project><artifactId>shop</artifactId><packaging>pom</packaging></project>");
write(
&root,
"gateway/pom.xml",
"<project><parent><artifactId>shop</artifactId></parent><artifactId>gateway</artifactId></project>",
);
write(
&root,
".run/Gateway.run.xml",
&run_file(
"Gateway",
r#"<configuration name="NAME" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
<module name="gateway" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.example.GatewayApplication" />
<option name="WORKING_DIRECTORY" value="file://$PROJECT_DIR$" />
<option name="ACTIVE_PROFILES" value="dev,local" />
<option name="VM_PARAMETERS" value="-Xmx512m" />
<option name="PROGRAM_PARAMETERS" value="--server.port=8081" />
<envs><env name="TZ" value="UTC" /></envs>
<method v="2"><option name="Make" enabled="true" /></method>
</configuration>"#,
),
);
write(
&root,
".run/Sub.run.xml",
&run_file(
"Sub",
r#"<configuration name="NAME" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
<module name="gateway" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.example.SubApplication" />
<option name="WORKING_DIRECTORY" value="file://$PROJECT_DIR$/gateway" />
<method v="2" />
</configuration>"#,
),
);
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#"
<configuration name="NAME" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
<module name="gateway" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.example.GatewayApplication" />
<option name="WORKING_DIRECTORY" value="{working_directory}" />
</configuration>"#
),
),
);
}
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#"<configuration name="NAME" type="PythonConfigurationType">
<option name="WORKING_DIRECTORY" value="{base}/jobs 中" />
<option name="SDK_HOME" value="{base}/bin/python" />
<option name="SCRIPT_NAME" value="{base}/jobs 中/run me.py" />
<option name="PARAMETERS" value="&quot;{base}/data file&quot; unchanged%20arg" />
<option name="ENV_FILES" value="{base}/.env dev{sep}{base}/.env local" />
<extension name="net.ashald.envfile">
<option name="IS_ENABLED" value="true" />
<ENTRIES><ENTRY PATH="{base}/.env plugin" PARSER="env" /></ENTRIES>
</extension>
</configuration>"#,
),
(
"Django",
r#"<configuration name="NAME" type="DjangoTestsConfigurationType">
<option name="CUSTOM_SETTINGS" value="true" />
<option name="SETTINGS_FILE" value="{base}/settings/dev.py" />
</configuration>"#,
),
(
"Pytest",
r#"<configuration name="NAME" type="tests" factoryName="pytest">
<option name="_new_targetType" value="&quot;PATH&quot;" />
<option name="_new_target" value="&quot;{base}/test file.py&quot;" />
<option name="_new_additionalArguments" value="&quot;'{base}/data file'&quot;" />
</configuration>"#,
),
(
"Npm",
r#"<configuration name="NAME" type="js.build_tools.npm">
<package-json value="{base}/web app/package.json" />
<package-manager value="{base}/bin/pnpm" />
<node-interpreter value="{base}/bin/node" />
<command value="run" /><scripts><script value="start" /></scripts>
</configuration>"#,
),
(
"Jest",
r#"<configuration name="NAME" type="JavaScriptTestRunnerJest">
<working-dir value="{base}/web app" />
<config-file value="{base}/web app/jest config.js" />
<scope-kind value="TEST_FILE" /><test-file value="{base}/web app/test file.js" />
</configuration>"#,
),
(
"Vitest",
r#"<configuration name="NAME" type="JavaScriptTestRunnerVitest">
<scope-kind value="DIRECTORY" /><test-directory value="{base}/test dir" />
</configuration>"#,
),
(
"Node",
r#"<configuration name="NAME" type="NodeJSConfigurationType"
working-dir="{base}/web app" path-to-js-file="{base}/web app/main file.js"
application-parameters="&quot;{base}/data file&quot;" />"#,
),
(
"Shell",
r#"<configuration name="NAME" type="ShConfigurationType">
<option name="SCRIPT_WORKING_DIRECTORY" value="{base}/jobs 中" />
<option name="SCRIPT_PATH" value="{base}/jobs 中/run me.sh" />
<option name="INTERPRETER_PATH" value="{base}/bin/sh" />
</configuration>"#,
),
];
write(&root, "manage.py", "");
for (name, body) in cases {
write(
&root,
&format!(".run/{name}.run.xml"),
&run_file(name, &body.replace("{base}", "file://$PROJECT_DIR$").replace("{sep}", &SEP.to_string())),
);
}
let r = import_from(&root, &[]);
assert!(r.skipped.is_empty(), "{r:#?}");
let python = get(&r, "Python");
assert_eq!(cwd(python), Some(PathBuf::from("jobs 中")));
assert_eq!(python.command, "../bin/python");
assert_eq!(python.args, s(&["run me.py", "../data file", "unchanged%20arg"]));
assert_eq!(python.extras.env_files, s(&[".env dev", ".env local", ".env plugin"]));
assert_eq!(get(&r, "Django").args, s(&["manage.py", "test", "--settings", "settings.dev"]));
assert_eq!(get(&r, "Pytest").args, s(&["-m", "pytest", "test file.py", "data file"]));
let npm = get(&r, "Npm");
assert_eq!(cwd(npm), Some(PathBuf::from("web app")));
assert_eq!(npm.command, "pnpm");
assert_eq!(Path::new(&npm.extras.params["nodeInterpreter"]), root.join("bin/node"));
assert_eq!(get(&r, "Jest").args, s(&["jest", "--config", "jest config.js", "test file.js"]));
assert_eq!(get(&r, "Vitest").args, s(&["vitest", "run", "test dir"]));
assert_eq!(get(&r, "Node").args, s(&["main file.js", "../data file"]));
let shell = get(&r, "Shell");
assert_eq!(shell.command, "../bin/sh");
assert_eq!(shell.args, s(&["run me.sh"]));
assert_eq!(shell.extras.params["path"], "run me.sh");
}
#[test]
fn file_urls_expand_module_and_home_macros() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
write(
&root,
".run/Home.run.xml",
&run_file(
"Home",
r#"<configuration name="NAME" type="PythonConfigurationType">
<option name="WORKING_DIRECTORY" value="file://$MODULE_DIR$/jobs dir" />
<option name="SCRIPT_NAME" value="file://$MODULE_WORKING_DIR$/jobs dir/main.py" />
<option name="SDK_HOME" value="file://$USER_HOME$/bin/python" />
</configuration>"#,
),
);
let mut environment = env();
let home = root.join("home").to_string_lossy().into_owned();
environment.insert("HOME".into(), home.clone());
environment.insert("USERPROFILE".into(), home);
let r = import(&root, RunImportSource::Jetbrains, &[], &environment).unwrap();
let home = get(&r, "Home");
assert_eq!(home.command, "../home/bin/python");
assert_eq!(home.args, s(&["main.py"]));
assert_eq!(cwd(home), Some(PathBuf::from("jobs dir")));
assert!(home.extras.local);
}