diff --git a/crates/ide-core/src/output_encoding.rs b/crates/ide-core/src/output_encoding.rs index 9aee05cb3..7d89e82a1 100644 --- a/crates/ide-core/src/output_encoding.rs +++ b/crates/ide-core/src/output_encoding.rs @@ -13,7 +13,10 @@ //! they fork get them too (`spring-boot:run`, Surefire, `bootRun`, test workers). //! Each JVM then reports "Picked up JAVA_TOOL_OPTIONS: …" on stderr (which a build tool //! may copy to its stdout); that line is hidden again ([`NoticeFilter`]). `file.encoding` is left alone there: it would change -//! how a build reads sources that declare no encoding. +//! how a build reads sources that declare no encoding. The application JVMs a build +//! forks get `file.encoding=UTF-8` for a UTF-8 console instead, through the build's own +//! options: `-Dspring-boot.run.jvmArguments` for Maven `spring-boot:run`, an init script +//! ([`GRADLE_FILE_ENCODING_SCRIPT`]) for Gradle `JavaExec` tasks (`bootRun`, `run`). //! - Python: `PYTHONIOENCODING`. use std::collections::BTreeMap; @@ -78,15 +81,34 @@ const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS"; const SPRING_BOOT_JVM_ARGS: &str = "-Dspring-boot.run.jvmArguments="; /// Build tools and launcher scripts that run on (and may fork) JVMs. -const JVM_TOOLS: &[&str] = - &["mvn", "mvnw", "mvnd", "gradle", "gradlew", "sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"]; +const JVM_TOOLS: &[&str] = &["sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"]; +const MAVEN: &[&str] = &["mvn", "mvnw", "mvnd"]; +const GRADLE: &[&str] = &["gradle", "gradlew"]; + +/// Gradle init script giving the `JavaExec` tasks (`bootRun`, `run`, custom ones) a UTF-8 +/// default charset, unless the task or the build already sets `file.encoding`. Checked when +/// the task runs, after the build script configured it; Gradle's own JVM and the compiler's +/// source encoding are untouched. +pub const GRADLE_FILE_ENCODING_SCRIPT: &str = "\ +// Written by OpenIDEA: a UTF-8 default charset for applications run by Gradle, as the Run console decodes UTF-8. +allprojects { + tasks.withType(JavaExec).configureEach { + doFirst { task -> + if (!task.allJvmArgs.any { it.startsWith('-Dfile.encoding=') }) task.jvmArgs('-Dfile.encoding=UTF-8') + } + } +} +"; /// What a launch needs: `-D` options to put first on a `java` command line, /// environment variables to set, and the notice line its JVMs print because of them. #[derive(Debug, Default, PartialEq, Eq)] pub struct EncodingFix { pub java_args: Vec, + /// The build tool's arguments, rewritten to pass options to the JVM it forks. pub args: Option>, + /// Gradle: pass [`GRADLE_FILE_ENCODING_SCRIPT`] with `--init-script`. + pub gradle_init_script: bool, pub env: Vec<(String, String)>, /// "Picked up JAVA_TOOL_OPTIONS: …" for a variable set here alone, to drop from the output. pub notice: Option, @@ -118,6 +140,8 @@ fn options(properties: &[&str], encoding: &str) -> Vec { #[derive(Clone, Copy, PartialEq, Eq)] enum Kind { Java, + Maven, + Gradle, JvmTool, Python, Other, @@ -127,6 +151,10 @@ fn kind(program: &str) -> Kind { let name = program_name(program); if name == "java" || name == "javaw" { Kind::Java + } else if MAVEN.contains(&name.as_str()) { + Kind::Maven + } else if GRADLE.contains(&name.as_str()) { + Kind::Gradle } else if JVM_TOOLS.contains(&name.as_str()) { Kind::JvmTool } else if is_python(&name) { @@ -195,12 +223,16 @@ fn fix_kind( out.java_args.push(format!("-D{FILE_ENCODING}=UTF-8")); } } - (Kind::Java | Kind::JvmTool, _) => { - if let (Kind::JvmTool, Some(args)) = (kind, args) + (Kind::Java | Kind::Maven | Kind::Gradle | Kind::JvmTool, _) => { + if let Some(args) = args && is_utf8(encoding) && !sets(&tool_options, &[FILE_ENCODING]) { - out.args = spring_boot_file_encoding(args); + match kind { + Kind::Maven => out.args = spring_boot_file_encoding(args), + Kind::Gradle => out.gradle_init_script = true, + _ => {} + } } if !sets(&tool_options, STD_PROPERTIES) { let added = options(STD_PROPERTIES, encoding).join(" "); @@ -484,6 +516,42 @@ mod tests { assert!(run("mvn", &["spring-boot:run"], &[], "UTF-8", false).args.is_none()); } + #[test] + fn forked_application_jvms_get_a_utf8_default_charset() { + // Reported: a Spring Boot app run by the build on Chinese Windows (JDK 8–17, GBK default). + for (tool, args) in [ + ("C:\\p\\gradlew.bat", &["bootRun"][..]), + ("gradle", &[":app:run"]), + ("mvnw.cmd", &["spring-boot:run"]), + ("mvnd", &["org.springframework.boot:spring-boot-maven-plugin:2.7.18:run"]), + ] { + let f = run(tool, args, &[], "UTF-8", true); + let gradle = tool.contains("gradle"); + assert_eq!(f.gradle_init_script, gradle, "{tool}"); + assert_eq!(f.args.is_some(), !gradle, "{tool}"); + // The build tool's own JVM keeps its default charset. + assert!(!tool_env(&f).unwrap().contains("file.encoding"), "{tool}"); + // The user's choice, a GBK console and other platforms are left alone. + let own = run(tool, args, &[(TOOL_OPTIONS, "-Dfile.encoding=GBK")], "UTF-8", true); + assert!(!own.gradle_init_script && own.args.is_none(), "{tool}"); + let gbk = run(tool, args, &[], "GBK", true); + assert!(!gbk.gradle_init_script && gbk.args.is_none(), "{tool}"); + assert_eq!(run(tool, args, &[], "UTF-8", false), EncodingFix::default(), "{tool}"); + } + // Shell command lines can't take arguments; other JVM tools fork no application. + assert!(!fix_command_line("gradlew bootRun", &BTreeMap::new(), |_| None, "UTF-8", true).gradle_init_script); + let sbt = run("sbt", &["run"], &[], "UTF-8", true); + assert!(!sbt.gradle_init_script && sbt.args.is_none()); + } + + #[test] + fn gradle_script_respects_the_builds_own_file_encoding() { + let s = GRADLE_FILE_ENCODING_SCRIPT; + assert!(s.contains("tasks.withType(JavaExec).configureEach"), "{s}"); + assert!(s.contains("allJvmArgs.any { it.startsWith('-Dfile.encoding=') }"), "{s}"); + assert!(!s.contains("withType(Test)") && !s.contains("JavaCompile"), "{s}"); + } + #[test] fn shell_command_lines_get_variables_from_their_first_word() { let line = |l: &str| fix_command_line(l, &BTreeMap::new(), |_| None, "UTF-8", true); diff --git a/crates/ide-run/src/gradle_jvm.rs b/crates/ide-run/src/gradle_jvm.rs index 0734a851c..cae12a3fd 100644 --- a/crates/ide-run/src/gradle_jvm.rs +++ b/crates/ide-run/src/gradle_jvm.rs @@ -41,17 +41,22 @@ pub fn default_dir() -> PathBuf { /// Writes the script for `jvm_args` under `dir` (named by its content, so launches share /// it and nothing needs deleting) and returns its path. pub fn write_init_script(dir: &Path, jvm_args: &[String]) -> std::io::Result { + write_script(dir, "jvm-args", &init_script(jvm_args)) +} + +/// Writes the init script `text` under `dir` as `-.init.gradle` (written +/// once per content, so launches share it) and returns its path. +pub fn write_script(dir: &Path, prefix: &str, text: &str) -> std::io::Result { use std::hash::{Hash, Hasher}; - let text = init_script(jvm_args); let mut h = std::collections::hash_map::DefaultHasher::new(); text.hash(&mut h); - let file = dir.join(format!("jvm-args-{:016x}.init.gradle", h.finish())); + let file = dir.join(format!("{prefix}-{:016x}.init.gradle", h.finish())); if std::fs::read_to_string(&file).is_ok_and(|t| t == text) { return Ok(file); } std::fs::create_dir_all(dir)?; let tmp = file.with_extension(format!("{}.tmp", std::process::id())); - std::fs::write(&tmp, &text)?; + std::fs::write(&tmp, text)?; std::fs::rename(&tmp, &file).inspect_err(|_| { let _ = std::fs::remove_file(&tmp); })?; diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 104c2d7d0..cb7b60562 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -195,10 +195,21 @@ fn match_console_encoding( output_encoding::fix(&program, &inv.args, &env.vars, inherited, encoding, cfg!(windows)) }; env.vars.extend(fix.env); - let changed = fix.args.is_some() || !fix.java_args.is_empty(); + let mut prepend = fix.java_args; + if fix.gradle_init_script { + match crate::gradle_jvm::write_script( + &crate::gradle_jvm::default_dir(), + "file-encoding", + output_encoding::GRADLE_FILE_ENCODING_SCRIPT, + ) { + Ok(script) => prepend.extend(["--init-script".to_owned(), script.to_string_lossy().into_owned()]), + Err(e) => tracing::warn!("cannot write the Gradle file.encoding init script: {e}"), + } + } + let changed = fix.args.is_some() || !prepend.is_empty(); if changed && !inv.raw_args { inv.args = fix.args.unwrap_or_else(|| std::mem::take(&mut inv.args)); - inv.args.splice(0..0, fix.java_args); + inv.args.splice(0..0, prepend); let mut display = quote(config.command.trim()); for a in &inv.args { display.push(' ');