diff --git a/crates/ide-build/src/process.rs b/crates/ide-build/src/process.rs index ac59e8e9..0ac37580 100644 --- a/crates/ide-build/src/process.rs +++ b/crates/ide-build/src/process.rs @@ -45,8 +45,12 @@ pub(crate) fn run( mut on_line: impl FnMut(Stream, &str), ) -> IdeResult { tracing::info!("build: {program} {} (in {})", args.join(" "), cwd.display()); + // Output is read as UTF-8: JVMs on Windows are told to write it (see `ide_core::output_encoding`). + let encoding = + ide_core::output_encoding::fix(program, args, env, |k| std::env::var(k).ok(), "UTF-8", cfg!(windows)); let mut command = Command::new(program); - command.args(args).current_dir(cwd).envs(env).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + command.args(args).current_dir(cwd).envs(env).envs(encoding.env).stdin(Stdio::null()); + command.stdout(Stdio::piped()).stderr(Stdio::piped()); #[cfg(unix)] { use std::os::unix::process::CommandExt; @@ -86,6 +90,7 @@ pub(crate) fn run( return Err(IdeError::cancelled()); } match rx.recv_timeout(Duration::from_millis(100)) { + Ok((_, line)) if encoding.notice.as_ref() == Some(&line) => {} Ok((stream, line)) => { on_line(stream, &line); text.push_str(&line); diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index c348c23d..49a31ff5 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod ignored; pub mod inspection_settings; pub mod ipc_path; pub mod jsonc; +pub mod output_encoding; pub mod process; pub mod project_dir; pub mod proxy_settings; diff --git a/crates/ide-core/src/output_encoding.rs b/crates/ide-core/src/output_encoding.rs new file mode 100644 index 00000000..5d3a3688 --- /dev/null +++ b/crates/ide-core/src/output_encoding.rs @@ -0,0 +1,412 @@ +//! Makes the output of JVM and Python processes match the encoding the IDE decodes. +//! +//! On Windows both write piped output in the ANSI code page (GBK on a Chinese +//! system: `System.out.println`, `javac` errors, Maven / Gradle logs, `print`), while +//! the console decodes the configured encoding (UTF-8 by default). They are told +//! which one to use: +//! - `java`: `stdout.encoding` / `stderr.encoding` (JDK 19+) and their `sun.*` +//! forerunners (JDK 8–18) on the command line, plus `file.encoding=UTF-8` for a +//! UTF-8 console, the default charset of JDK 18+, which logging frameworks' console +//! appenders write in on older JDKs too. +//! - Build tools and launcher scripts (Maven, Gradle, sbt, …): the same `std*.encoding` +//! properties in `JAVA_TOOL_OPTIONS`, the one variable every JVM reads, so the JVMs +//! 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. +//! - Python: `PYTHONIOENCODING`. + +use std::collections::BTreeMap; + +const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"]; +const FILE_ENCODING: &str = "file.encoding"; +const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS"; + +/// 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"]; + +/// 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, + pub env: Vec<(String, String)>, + /// "Picked up JAVA_TOOL_OPTIONS: …" for a variable set here alone, to drop from the output. + pub notice: Option, +} + +pub fn is_utf8(encoding: &str) -> bool { + matches!(encoding.trim().to_ascii_lowercase().replace('_', "-").as_str(), "utf-8" | "utf8") +} + +/// File name of `command` without a Windows executable extension: `C:\jdk\bin\java.exe` → `java`. +fn program_name(command: &str) -> String { + let name = command.rsplit(['/', '\\']).next().unwrap_or(command).to_ascii_lowercase(); + for ext in [".exe", ".cmd", ".bat"] { + if let Some(stem) = name.strip_suffix(ext) { + return stem.to_owned(); + } + } + name +} + +fn sets(text: &str, properties: &[&str]) -> bool { + properties.iter().any(|p| text.contains(&format!("-D{p}="))) +} + +fn options(properties: &[&str], encoding: &str) -> Vec { + properties.iter().map(|p| format!("-D{p}={encoding}")).collect() +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Kind { + Java, + JvmTool, + Python, + Other, +} + +fn kind(program: &str) -> Kind { + let name = program_name(program); + if name == "java" || name == "javaw" { + Kind::Java + } else if JVM_TOOLS.contains(&name.as_str()) { + Kind::JvmTool + } else if is_python(&name) { + Kind::Python + } else { + Kind::Other + } +} + +/// The options for a launch of `program` (`args`, environment `vars` over the +/// `inherited` one) whose output is decoded as `encoding`; nothing when the +/// process already writes it (UTF-8 off Windows) or the user chose the options. +pub fn fix( + program: &str, + args: &[String], + vars: &BTreeMap, + inherited: impl Fn(&str) -> Option, + encoding: &str, + windows: bool, +) -> EncodingFix { + fix_kind(kind(program), Some(args), vars, inherited, encoding, windows) +} + +/// [`fix`] for a command line run by the shell (`cmd /C "mvn spring-boot:run"`), whose +/// arguments cannot be changed: its first word decides, and only variables are set. +pub fn fix_command_line( + line: &str, + vars: &BTreeMap, + inherited: impl Fn(&str) -> Option, + encoding: &str, + windows: bool, +) -> EncodingFix { + let line = line.trim_start(); + let first = match line.chars().next() { + Some(q @ ('"' | '\'')) => line[1..].split(q).next(), + _ => line.split(char::is_whitespace).next(), + } + .unwrap_or_default(); + fix_kind(kind(first), None, vars, inherited, encoding, windows) +} + +fn fix_kind( + kind: Kind, + args: Option<&[String]>, + vars: &BTreeMap, + inherited: impl Fn(&str) -> Option, + encoding: &str, + windows: bool, +) -> EncodingFix { + let encoding = encoding.trim(); + let mut out = EncodingFix::default(); + if encoding.is_empty() || (!windows && is_utf8(encoding)) { + return out; + } + let current = |key: &str| vars.get(key).cloned().or_else(|| inherited(key)).unwrap_or_default(); + let tool_options = current(TOOL_OPTIONS); + match (kind, args) { + (Kind::Java, Some(args)) => { + let java_options = format!("{} {tool_options}", current("JDK_JAVA_OPTIONS")); + let chosen = + |properties: &[&str]| args.iter().any(|a| sets(a, properties)) || sets(&java_options, properties); + if !chosen(STD_PROPERTIES) { + out.java_args = options(STD_PROPERTIES, encoding); + } + if is_utf8(encoding) && !chosen(&[FILE_ENCODING]) { + out.java_args.push(format!("-D{FILE_ENCODING}=UTF-8")); + } + } + (Kind::Java | Kind::JvmTool, _) => { + if !sets(&tool_options, STD_PROPERTIES) { + let added = options(STD_PROPERTIES, encoding).join(" "); + let value = match tool_options.trim() { + "" => { + out.notice = Some(format!("Picked up {TOOL_OPTIONS}: {added}")); + added + } + own => format!("{own} {added}"), + }; + out.env.push((TOOL_OPTIONS.to_owned(), value)); + } + } + (Kind::Python, _) if current("PYTHONIOENCODING").trim().is_empty() => { + out.env.push(("PYTHONIOENCODING".to_owned(), encoding.to_owned())); + } + _ => {} + } + out +} + +/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding` +/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself. +pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option { + let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8"); + if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) { + return None; + } + let added = options(STD_PROPERTIES, encoding).join(" "); + Some(match vm_args.trim() { + "" => added, + own => format!("{own} {added}"), + }) +} + +/// `python`, `python3`, `python3.12`, `py`, and the runners that start one. +fn is_python(name: &str) -> bool { + name == "python" + || name == "py" + || name.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.')) + || matches!(name, "uv" | "poetry" | "pdm" | "pytest" | "pipenv") +} + +/// Drops whole lines equal to `notice` from a stream of decoded text (the +/// [`EncodingFix::notice`] each JVM prints), passing everything else through. +/// The start of a line is held back only while it can still become the notice. +#[derive(Debug)] +pub struct NoticeFilter { + notice: String, + held: String, + at_line_start: bool, +} + +impl NoticeFilter { + pub fn new(notice: String) -> Self { + Self { notice, held: String::new(), at_line_start: true } + } + + pub fn filter(&mut self, text: &str, out: &mut String) { + for piece in text.split_inclusive('\n') { + if !self.at_line_start && self.held.is_empty() { + out.push_str(piece); + self.at_line_start = piece.ends_with('\n'); + continue; + } + self.held.push_str(piece); + if self.held.ends_with('\n') { + if self.held.trim_end_matches(['\n', '\r']) != self.notice { + out.push_str(&self.held); + } + self.held.clear(); + self.at_line_start = true; + } else if !self.could_be_notice() { + out.push_str(&self.held); + self.held.clear(); + self.at_line_start = false; + } + } + } + + /// The held text is the start of the notice, or all of it before a `\r\n`. + fn could_be_notice(&self) -> bool { + match self.held.strip_suffix('\r') { + Some(line) => line == self.notice, + None => self.notice.starts_with(self.held.as_str()), + } + } + + /// The held-back start of a line, at the end of the stream. + pub fn finish(&mut self) -> String { + std::mem::take(&mut self.held) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const STD_UTF8: [&str; 4] = [ + "-Dstdout.encoding=UTF-8", + "-Dstderr.encoding=UTF-8", + "-Dsun.stdout.encoding=UTF-8", + "-Dsun.stderr.encoding=UTF-8", + ]; + const TOOL_UTF8: &str = + "-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8"; + + fn vars(vars: &[(&str, &str)]) -> BTreeMap { + vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect() + } + + fn run(program: &str, args: &[&str], env: &[(&str, &str)], encoding: &str, windows: bool) -> EncodingFix { + let args: Vec = args.iter().map(|a| (*a).to_owned()).collect(); + fix(program, &args, &vars(env), |_| None, encoding, windows) + } + + fn tool_env(f: &EncodingFix) -> Option<&str> { + f.env.iter().find(|(k, _)| k == TOOL_OPTIONS).map(|(_, v)| v.as_str()) + } + + #[test] + fn java_launches_write_the_console_encoding_on_windows() { + let mut expected: Vec<&str> = STD_UTF8.to_vec(); + expected.push("-Dfile.encoding=UTF-8"); + // A source-file launch through the Oracle `javapath` shim, and an Application / Spring Boot launch. + let java = "C:/Program Files/Common Files/Oracle/Java/javapath/java.exe"; + for program in [java, "java", "javaw.exe", r"C:\jdk\bin\java.exe", "JAVA.EXE"] { + for args in [&["Main.java"][..], &["-cp", "a;b", "org.dromara.DromaraApplication"]] { + let f = run(program, args, &[], "UTF-8", true); + assert_eq!(f.java_args, expected, "{program}"); + assert!(f.env.is_empty() && f.notice.is_none()); + } + } + // Another console encoding: the streams follow it, the default charset stays the platform's. + assert_eq!( + run("java", &["-cp", "x", "a.Main"], &[], "GBK", true).java_args, + [ + "-Dstdout.encoding=GBK", + "-Dstderr.encoding=GBK", + "-Dsun.stdout.encoding=GBK", + "-Dsun.stderr.encoding=GBK" + ] + ); + } + + #[test] + fn nothing_changes_where_output_already_matches() { + for (program, args) in [("java", vec!["Main.java"]), ("mvn", vec!["package"]), ("python3", vec!["a.py"])] { + assert_eq!(run(program, &args, &[], "UTF-8", false), EncodingFix::default(), "{program}"); + assert_eq!(run(program, &args, &[], "utf8", false), EncodingFix::default(), "{program}"); + assert_eq!(run(program, &args, &[], "", true), EncodingFix::default(), "{program}"); + } + for program in ["node", "cargo", "go", "npm.cmd", "cmd.exe", "javac-wrapper"] { + assert_eq!(run(program, &[], &[], "UTF-8", true), EncodingFix::default(), "{program}"); + } + // A console set to another encoding elsewhere still gets it. + assert_eq!(run("java", &["Main"], &[], "GBK", false).java_args.len(), 4); + } + + #[test] + fn options_chosen_by_the_user_win() { + let f = run("java", &["-Dstdout.encoding=GBK", "Main"], &[], "UTF-8", true); + assert_eq!(f.java_args, ["-Dfile.encoding=UTF-8"]); + let f = run("java", &["-Dfile.encoding=GBK", "Main"], &[], "UTF-8", true); + assert_eq!(f.java_args, STD_UTF8); + for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] { + let f = run("java", &["Main"], &[(key, "-Dsun.stdout.encoding=GBK -Dfile.encoding=GBK")], "UTF-8", true); + assert!(f.java_args.is_empty(), "{key}"); + } + assert!(run("mvn.cmd", &[], &[(TOOL_OPTIONS, "-Dstdout.encoding=GBK")], "UTF-8", true).env.is_empty()); + assert!(run("python.exe", &[], &[("PYTHONIOENCODING", "gbk")], "UTF-8", true).env.is_empty()); + } + + /// Maven's `spring-boot:run` and Surefire, Gradle's `bootRun` and test workers fork JVMs + /// that never see `MAVEN_OPTS` / `GRADLE_OPTS`: the options go where every JVM reads them. + #[test] + fn build_tools_reach_the_jvms_they_fork() { + for program in [ + "mvn.cmd", + "mvnw.cmd", + r"C:\maven\bin\mvn.cmd", + "mvnd.cmd", + "gradlew.bat", + "gradle.bat", + "sbt.bat", + "kotlinc.bat", + ] { + let f = run(program, &["spring-boot:run"], &[], "UTF-8", true); + assert_eq!(tool_env(&f), Some(TOOL_UTF8), "{program}"); + assert_eq!(f.notice.as_deref(), Some(format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}").as_str())); + assert!(f.java_args.is_empty()); + assert!(!tool_env(&f).unwrap().contains("file.encoding"), "a build's source encoding is left alone"); + } + // Existing options are kept, from the configuration or the inherited environment; the + // JVMs then print the user's own variable, which stays visible. + let f = run("mvn.cmd", &[], &[(TOOL_OPTIONS, "-javaagent:a.jar")], "UTF-8", true); + assert_eq!(tool_env(&f), Some(format!("-javaagent:a.jar {TOOL_UTF8}").as_str())); + assert!(f.notice.is_none()); + let inherited = + fix("gradlew.bat", &[], &BTreeMap::new(), |k| (k == TOOL_OPTIONS).then(|| "-Xss4m".into()), "UTF-8", true); + assert_eq!(tool_env(&inherited), Some(format!("-Xss4m {TOOL_UTF8}").as_str())); + for program in ["python.exe", "python3", "python3.12", "py.exe", "uv.exe", "poetry"] { + assert_eq!( + run(program, &[], &[], "UTF-8", true).env, + [("PYTHONIOENCODING".to_owned(), "UTF-8".to_owned())], + "{program}" + ); + } + assert_eq!(run("python3", &[], &[], "GBK", false).env, [("PYTHONIOENCODING".to_owned(), "GBK".to_owned())]); + } + + #[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); + for l in [ + "mvn spring-boot:run -pl ruoyi-admin", + "\"C:\\Program Files\\Maven\\bin\\mvn.cmd\" test", + "java -jar app.jar", + ] { + let f = line(l); + assert_eq!(tool_env(&f), Some(TOOL_UTF8), "{l}"); + assert!(f.java_args.is_empty()); + } + assert_eq!(line("python manage.py runserver").env[0].0, "PYTHONIOENCODING"); + assert_eq!(line("npm run dev && echo done"), EncodingFix::default()); + assert_eq!(fix_command_line("mvn test", &BTreeMap::new(), |_| None, "UTF-8", false), EncodingFix::default()); + } + + #[test] + fn java_debug_launches_write_what_java_debug_decodes() { + assert_eq!(java_debug_vm_args("", None, true).as_deref(), Some(TOOL_UTF8)); + assert_eq!(java_debug_vm_args("-Xmx1g", Some(""), true), Some(format!("-Xmx1g {TOOL_UTF8}"))); + assert_eq!(java_debug_vm_args("", Some("GBK"), true).unwrap().split(' ').next(), Some("-Dstdout.encoding=GBK")); + assert_eq!(java_debug_vm_args("-Dstdout.encoding=GBK", None, true), None); + assert_eq!(java_debug_vm_args("-Xmx1g", None, false), None); + assert!(java_debug_vm_args("", Some("GBK"), false).is_some()); + } + + fn filtered(chunks: &[&str]) -> String { + let mut f = NoticeFilter::new(format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}")); + let mut out = String::new(); + for c in chunks { + f.filter(c, &mut out); + } + out.push_str(&f.finish()); + out + } + + #[test] + fn notice_lines_are_dropped_wherever_the_chunks_split() { + let notice = format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}"); + let text = + format!("{notice}\r\nerror: one\n{notice}\nPicked up nothing\nPicked up JAVA_TOOL_OPTIONS: -Dx\nlast"); + let expected = "error: one\nPicked up nothing\nPicked up JAVA_TOOL_OPTIONS: -Dx\nlast"; + assert_eq!(filtered(&[&text]), expected); + // Every split point, also inside the notice and between `\r` and `\n`. + for i in 1..text.len() { + let (a, b) = text.split_at(i); + assert_eq!(filtered(&[a, b]), expected, "split at {i}"); + } + // A line that only starts like it is not held: text mid-line passes through at once. + let mut f = NoticeFilter::new(notice.clone()); + let mut out = String::new(); + f.filter("Pick a number: ", &mut out); + assert_eq!(out, "Pick a number: "); + f.filter("Picked", &mut out); + assert_eq!(out, "Pick a number: Picked"); + // A prefix at the end of the stream comes out on finish. + assert_eq!(filtered(&["Picked up"]), "Picked up"); + } +} diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 159723eb..63bf3c63 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; /// Starts a session owned by this window: its events (and those of child sessions) go here only. pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult { + let config = match_java_debug_encoding(config, cfg!(windows)); let label = window; let owner = SessionOwner::new(host.owner_window(label), host.window_sink(label)); let settings = match host.project(label) { @@ -20,6 +21,26 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide host.debugger.start_with(config, owner, custom).await } +/// A JVM launched by java-debug writes what java-debug decodes its output as +/// (see [`ide_core::output_encoding::java_debug_vm_args`]). +fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchConfig { + if config.adapter != "java-debug" || config.request != "launch" { + return config; + } + let Some(serde_json::Value::Object(mut extra)) = + config.extra_json.as_deref().and_then(|e| serde_json::from_str(e).ok()) + else { + return config; + }; + let vm_args = extra.get("vmArgs").and_then(|v| v.as_str()).unwrap_or_default(); + let encoding = extra.get("encoding").and_then(|v| v.as_str()); + if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(vm_args, encoding, windows) { + extra.insert("vmArgs".into(), vm_args.into()); + config.extra_json = Some(serde_json::Value::Object(extra).to_string()); + } + config +} + pub async fn debug_set_breakpoints( host: &Host, _window: &str, @@ -355,3 +376,46 @@ pub(crate) fn register(d: &mut Dispatcher) { |c| async move { debug_redefine_classes(&c.host, &c.window, c.arg("session")?).await }, ); } + +#[cfg(test)] +mod tests { + use super::*; + + fn launch(adapter: &str, request: &str, extra: serde_json::Value) -> LaunchConfig { + LaunchConfig { + name: "DromaraApplication".into(), + adapter: adapter.into(), + request: request.into(), + program: None, + args: vec![], + cwd: None, + env: Default::default(), + extra_json: Some(extra.to_string()), + } + } + + fn vm_args(config: &LaunchConfig) -> serde_json::Value { + serde_json::from_str::(config.extra_json.as_deref().unwrap()).unwrap()["vmArgs"].clone() + } + + #[test] + fn java_debug_launches_on_windows_write_utf8() { + let extra = + serde_json::json!({"mainClass": "org.dromara.DromaraApplication", "vmArgs": "-Xmx1g", "debugServer": 1}); + let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true); + assert_eq!( + vm_args(&fixed), + "-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8" + ); + assert_eq!( + serde_json::from_str::(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"], + "org.dromara.DromaraApplication" + ); + // Attaching, other adapters and other platforms are left alone. + for (adapter, request, windows) in + [("java-debug", "attach", true), ("debugpy", "launch", true), ("java-debug", "launch", false)] + { + assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g"); + } + } +} diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index d2110912..7d448b17 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -6,7 +6,6 @@ pub mod envfile; pub mod gradle_jvm; pub mod java_cmdline; pub mod junit; -mod output_encoding; mod process; mod utf8; pub mod which; diff --git a/crates/ide-run/src/output_encoding.rs b/crates/ide-run/src/output_encoding.rs deleted file mode 100644 index 76c6c4b5..00000000 --- a/crates/ide-run/src/output_encoding.rs +++ /dev/null @@ -1,167 +0,0 @@ -//! Makes the output of JVM and Python processes match the console's encoding. -//! -//! On Windows both write piped output in the ANSI code page (GBK on a Chinese -//! system: `javac` errors, Maven / Gradle logs, `print`), while the console decodes -//! the configured encoding (UTF-8 by default). They are told which one to use: -//! `java` through `stdout.encoding` / `stderr.encoding` (JDK 19+) and their -//! `sun.*` forerunners (JDK 8–18), Maven and Gradle through `MAVEN_OPTS` / -//! `GRADLE_OPTS`, Python through `PYTHONIOENCODING`. - -use std::collections::BTreeMap; - -const JAVA_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"]; - -/// What a launch needs: `-D` options to put first on a `java` command line, and -/// environment variables to set. -#[derive(Debug, Default, PartialEq, Eq)] -pub struct EncodingFix { - pub java_args: Vec, - pub env: Vec<(String, String)>, -} - -fn is_utf8(encoding: &str) -> bool { - matches!(encoding.to_ascii_lowercase().replace('_', "-").as_str(), "utf-8" | "utf8") -} - -fn java_options(encoding: &str) -> Vec { - JAVA_PROPERTIES.iter().map(|p| format!("-D{p}={encoding}")).collect() -} - -fn sets_java_encoding(text: &str) -> bool { - JAVA_PROPERTIES.iter().any(|p| text.contains(&format!("-D{p}="))) -} - -/// The options for a launch of `program` (`args`, environment `vars` over the -/// `inherited` one) whose output is decoded as `encoding`; nothing when the -/// process already writes it (UTF-8 off Windows) or the user chose the options. -pub fn fix( - program: &str, - args: &[String], - vars: &BTreeMap, - inherited: impl Fn(&str) -> Option, - encoding: &str, - windows: bool, -) -> EncodingFix { - let encoding = encoding.trim(); - let mut out = EncodingFix::default(); - if encoding.is_empty() || (!windows && is_utf8(encoding)) { - return out; - } - let current = |key: &str| vars.get(key).cloned().or_else(|| inherited(key)); - let name = crate::wrap::program_name(program).to_ascii_lowercase(); - match name.as_str() { - "java" | "javaw" => { - if !args.iter().any(|a| sets_java_encoding(a)) - && !current("JDK_JAVA_OPTIONS").is_some_and(|o| sets_java_encoding(&o)) - { - out.java_args = java_options(encoding); - } - } - "mvn" | "mvnw" | "mvnd" | "gradle" | "gradlew" => { - let key = if name.starts_with("gradle") { "GRADLE_OPTS" } else { "MAVEN_OPTS" }; - let opts = current(key).unwrap_or_default(); - if !sets_java_encoding(&opts) { - let added = java_options(encoding).join(" "); - let value = if opts.trim().is_empty() { added } else { format!("{} {added}", opts.trim()) }; - out.env.push((key.to_owned(), value)); - } - } - _ if is_python(&name) && current("PYTHONIOENCODING").is_none_or(|v| v.trim().is_empty()) => { - out.env.push(("PYTHONIOENCODING".to_owned(), encoding.to_owned())); - } - _ => {} - } - out -} - -/// `python`, `python3`, `python3.12`, `py`, and the runners that start one. -fn is_python(name: &str) -> bool { - name == "python" - || name == "py" - || name.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.')) - || matches!(name, "uv" | "poetry" | "pdm" | "pytest" | "pipenv") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn run(program: &str, args: &[&str], vars: &[(&str, &str)], encoding: &str, windows: bool) -> EncodingFix { - let args: Vec = args.iter().map(|a| (*a).to_owned()).collect(); - let vars: BTreeMap = vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(); - fix(program, &args, &vars, |_| None, encoding, windows) - } - - #[test] - fn java_launches_write_the_console_encoding_on_windows() { - let expected = [ - "-Dstdout.encoding=UTF-8", - "-Dstderr.encoding=UTF-8", - "-Dsun.stdout.encoding=UTF-8", - "-Dsun.stderr.encoding=UTF-8", - ]; - // The reported case: a source-file launch through the Oracle `javapath` shim. - let java = "C:/Program Files/Common Files/Oracle/Java/javapath/java.exe"; - for program in [java, "java", "javaw.exe", r"C:\jdk\bin\java.exe"] { - let f = run(program, &["Main.java"], &[], "UTF-8", true); - assert_eq!(f.java_args, expected, "{program}"); - assert!(f.env.is_empty()); - } - assert_eq!(run("java", &["-cp", "x", "a.Main"], &[], "GBK", true).java_args[0], "-Dstdout.encoding=GBK"); - } - - #[test] - fn nothing_changes_where_output_already_matches() { - for (program, args) in [("java", vec!["Main.java"]), ("mvn", vec!["package"]), ("python3", vec!["a.py"])] { - assert_eq!(run(program, &args, &[], "UTF-8", false), EncodingFix::default(), "{program}"); - assert_eq!(run(program, &args, &[], "utf8", false), EncodingFix::default(), "{program}"); - assert_eq!(run(program, &args, &[], "", true), EncodingFix::default(), "{program}"); - } - for program in ["node", "cargo", "go", "npm.cmd", "cmd.exe"] { - assert_eq!(run(program, &[], &[], "UTF-8", true), EncodingFix::default(), "{program}"); - } - // A console set to another encoding elsewhere still gets it. - assert_eq!(run("java", &["Main"], &[], "GBK", false).java_args.len(), 4); - } - - #[test] - fn options_chosen_by_the_user_win() { - assert!(run("java", &["-Dstdout.encoding=GBK", "Main"], &[], "UTF-8", true).java_args.is_empty()); - assert!( - run("java", &["Main"], &[("JDK_JAVA_OPTIONS", "-Dsun.stdout.encoding=GBK")], "UTF-8", true) - .java_args - .is_empty() - ); - assert!(run("mvn.cmd", &[], &[("MAVEN_OPTS", "-Dstdout.encoding=GBK")], "UTF-8", true).env.is_empty()); - assert!(run("python.exe", &[], &[("PYTHONIOENCODING", "gbk")], "UTF-8", true).env.is_empty()); - } - - #[test] - fn build_tools_and_python_get_environment_variables() { - let opts = - "-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8"; - for program in ["mvn.cmd", "mvnw.cmd", r"C:\maven\bin\mvn.cmd"] { - assert_eq!( - run(program, &["package"], &[], "UTF-8", true).env, - [("MAVEN_OPTS".to_owned(), opts.to_owned())] - ); - } - for program in ["gradlew.bat", "gradle.bat"] { - assert_eq!(run(program, &["build"], &[], "UTF-8", true).env, [("GRADLE_OPTS".to_owned(), opts.to_owned())]); - } - // Existing options are kept, from the configuration or the inherited environment. - let f = run("mvn.cmd", &[], &[("MAVEN_OPTS", "-Xmx1g")], "UTF-8", true); - assert_eq!(f.env, [("MAVEN_OPTS".to_owned(), format!("-Xmx1g {opts}"))]); - let inherited = - fix("gradlew.bat", &[], &BTreeMap::new(), |k| (k == "GRADLE_OPTS").then(|| "-Xmx2g".into()), "UTF-8", true); - assert_eq!(inherited.env, [("GRADLE_OPTS".to_owned(), format!("-Xmx2g {opts}"))]); - for program in ["python.exe", "python3", "python3.12", "py.exe", "uv.exe", "poetry"] { - assert_eq!( - run(program, &[], &[], "UTF-8", true).env, - [("PYTHONIOENCODING".to_owned(), "UTF-8".to_owned())], - "{program}" - ); - } - assert_eq!(run("python3", &[], &[], "GBK", false).env, [("PYTHONIOENCODING".to_owned(), "GBK".to_owned())]); - } -} diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 216e8245..1cf066dd 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -106,6 +106,8 @@ pub(crate) struct Invocation { pub args: Vec, /// Passed verbatim (Windows `cmd /S /C "…"`: cmd does not parse C runtime quoting). pub raw_args: bool, + /// A command line run by the platform shell (`display` is the line). + pub shell: bool, pub display: String, } @@ -141,7 +143,7 @@ pub(crate) fn invocation(config: &RunConfig, cwd: &Path) -> Invocation { display.push(' '); display.push_str("e(a)); } - Invocation { program, args: config.args.clone(), raw_args: false, display } + Invocation { program, args: config.args.clone(), raw_args: false, shell: false, display } } /// "Shorten command line" for a `java` launch too long for the platform (see @@ -172,22 +174,25 @@ fn shorten_java(inv: &mut Invocation, config: &RunConfig, cwd: &Path) -> IdeResu } /// Tells JVM and Python processes to write the encoding the console decodes (see -/// [`crate::output_encoding`]). -fn match_console_encoding(inv: &mut Invocation, env: &mut LaunchEnv, config: &RunConfig, options: &StartOptions) { - if inv.raw_args { - return; - } +/// [`ide_core::output_encoding`]); returns the notice line to hide from the output. +fn match_console_encoding( + inv: &mut Invocation, + env: &mut LaunchEnv, + config: &RunConfig, + options: &StartOptions, +) -> Option { + use ide_core::output_encoding; let inherit = !env.clear; - let fix = crate::output_encoding::fix( - &inv.program.to_string_lossy(), - &inv.args, - &env.vars, - |key| if inherit { std::env::var(key).ok() } else { None }, - options.encoding.as_deref().unwrap_or("UTF-8"), - cfg!(windows), - ); + let inherited = |key: &str| if inherit { std::env::var(key).ok() } else { None }; + let encoding = options.encoding.as_deref().unwrap_or("UTF-8"); + let fix = if inv.shell { + output_encoding::fix_command_line(&inv.display, &env.vars, inherited, encoding, cfg!(windows)) + } else { + let program = inv.program.to_string_lossy(); + output_encoding::fix(&program, &inv.args, &env.vars, inherited, encoding, cfg!(windows)) + }; env.vars.extend(fix.env); - if !fix.java_args.is_empty() { + if !fix.java_args.is_empty() && !inv.raw_args { inv.args.splice(0..0, fix.java_args); let mut display = quote(config.command.trim()); for a in &inv.args { @@ -196,6 +201,7 @@ fn match_console_encoding(inv: &mut Invocation, env: &mut LaunchEnv, config: &Ru } inv.display = display; } + fix.notice } /// A launch's pathing jar / argfile, deleted when dropped (the process exited, or never started). @@ -217,6 +223,7 @@ fn shell_invocation(command: &str, windows: bool, comspec: Option) -> In program: comspec.filter(|c| !c.is_empty()).unwrap_or_else(|| "cmd.exe".into()).into(), args: vec!["/D".into(), "/S".into(), "/C".into(), format!("\"{command}\"")], raw_args: true, + shell: true, display: command.into(), }; } @@ -224,6 +231,7 @@ fn shell_invocation(command: &str, windows: bool, comspec: Option) -> In program: "/bin/sh".into(), args: vec!["-c".into(), command.into()], raw_args: false, + shell: true, display: command.into(), } } @@ -313,10 +321,9 @@ impl ProcessManager { cwd: cwd.clone(), env: env.clone(), }); - if !extras.emulate_terminal { - // A terminal re-encodes output itself; pipes carry the process's bytes as they are. - match_console_encoding(&mut inv, &mut env, config, options); - } + // A terminal re-encodes output itself; pipes carry the process's bytes as they are. + let notice = + if extras.emulate_terminal { None } else { match_console_encoding(&mut inv, &mut env, config, options) }; let temp = shorten_java(&mut inv, config, &cwd)?; if extras.emulate_terminal { return self.start_pty(config, &inv, &cwd, &env, stop, options, sink, on_output, temp); @@ -391,7 +398,7 @@ impl ProcessManager { if let Some(err) = stderr { spawn_reader(id, Stream::Stderr, err, tx.clone())?; } - spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone())?; + spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), notice)?; Ok(()) })(); if let Err(e) = result { @@ -484,7 +491,7 @@ impl ProcessManager { let (tx, rx) = mpsc::channel(); let result = spawn_reader(id, Stream::Stdout, reader, tx.clone()) - .and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone())); + .and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), None)); if let Err(e) = result { let _ = child.kill(); let _ = child.wait(); @@ -807,10 +814,16 @@ fn spawn_pump( entry: Arc, sink: SharedSink, encoding: Option, + notice: Option, ) -> std::io::Result<()> { std::thread::Builder::new().name(format!("openidea-run-pump-{}", entry.id.0)).spawn(move || { let mut out_dec = OutputDecoder::for_label(encoding.as_deref()); let mut err_dec = OutputDecoder::for_label(encoding.as_deref()); + // "Picked up JAVA_TOOL_OPTIONS: …" from every JVM given the console encoding, on + // either stream (`spring-boot:run` copies its fork's stderr to Maven's stdout). + let filter = || notice.clone().map(ide_core::output_encoding::NoticeFilter::new); + let (mut out_notice, mut err_notice) = (filter(), filter()); + let mut decoded = String::new(); let mut pending = String::new(); let mut pending_stream = Stream::Stdout; let mut batch_started: Option = None; @@ -840,8 +853,18 @@ fn spawn_pump( flush(&mut pending, pending_stream, &mut batch_started); pending_stream = stream; } - let dec = if stream == Stream::Stdout { &mut out_dec } else { &mut err_dec }; - dec.decode_into(&bytes, &mut pending); + let (dec, notice) = match stream { + Stream::Stdout => (&mut out_dec, &mut out_notice), + Stream::Stderr => (&mut err_dec, &mut err_notice), + }; + match notice { + Some(filter) => { + dec.decode_into(&bytes, &mut decoded); + filter.filter(&decoded, &mut pending); + decoded.clear(); + } + None => dec.decode_into(&bytes, &mut pending), + } if !pending.is_empty() && batch_started.is_none() { batch_started = Some(Instant::now()); } @@ -865,8 +888,15 @@ fn spawn_pump( } } flush(&mut pending, pending_stream, &mut batch_started); - for (stream, dec) in [(Stream::Stdout, &mut out_dec), (Stream::Stderr, &mut err_dec)] { - let rest = dec.finish(); + for (stream, dec, notice) in + [(Stream::Stdout, &mut out_dec, &mut out_notice), (Stream::Stderr, &mut err_dec, &mut err_notice)] + { + let mut rest = dec.finish(); + if let Some(filter) = notice { + let tail = std::mem::take(&mut rest); + filter.filter(&tail, &mut rest); + rest.push_str(&filter.finish()); + } if !rest.is_empty() { on_output(chunk(stream, rest)); } diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 36fb5bf6..5567466c 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -229,6 +229,39 @@ fn builds_nested_in_a_folder_of_repositories() { assert_eq!(c.iter().filter(|c| c.kind == "spring-boot").count(), 1); } +/// RuoYi-AI opened from the folder above it (`ites-agent/ruoyi-ai`), or two folders above: +/// its application class builds the `SpringApplication` itself. +#[test] +fn spring_boot_application_of_a_repository_nested_one_or_two_levels_down() { + const MAIN: &str = "package org.ruoyi;\n\nimport org.springframework.boot.SpringApplication;\n\ + import org.springframework.boot.autoconfigure.SpringBootApplication;\n\n\ + /**\n * 启动程序\n *\n * @author Lion Li\n */\n@SpringBootApplication\n\ + public class RuoYiAIApplication {\n\n public static void main(String[] args) {\n\ + \x20 SpringApplication application = new SpringApplication(RuoYiAIApplication.class);\n\ + \x20 application.run(args);\n\ + \x20 System.out.println(\"(♥◠‿◠)ノ゙ RuoYi-AI启动成功 ლ(´ڡ`ლ)\");\n }\n}\n"; + for prefix in ["", "ites-agent/"] { + let file = |p: &str| format!("{prefix}ruoyi-ai/{p}"); + let files = [ + (file("pom.xml"), PARENT_POM), + (file("ruoyi-admin/pom.xml"), BOOT_POM), + (file("ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java"), MAIN), + (file("ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIServletInitializer.java"), "package org.ruoyi;"), + (file("ruoyi-common/pom.xml"), "pom"), + (file("ruoyi-modules/ruoyi-chat/pom.xml"), ""), + (file("docs/README.md"), "docs"), + ]; + let files: Vec<(&str, &str)> = files.iter().map(|(p, t)| (p.as_str(), *t)).collect(); + let dir = project(&files); + let c = detect(dir.path()); + let module = format!("{prefix}ruoyi-ai/ruoyi-admin"); + let app = find(&c, &format!("application:{module}:org.ruoyi.RuoYiAIApplication")); + assert_eq!((app.name.as_str(), app.kind.as_str()), ("RuoYiAIApplication", "spring-boot"), "{prefix}"); + assert_eq!(app.cwd.as_deref(), Some(Path::new(&module))); + assert!(ids(&c).contains(&format!("maven:spring-boot:run:{prefix}ruoyi-ai").as_str()), "{prefix}"); + } +} + #[test] fn nested_builds_of_every_kind_and_depth() { let dir = project(&[ diff --git a/crates/ide-run/tests/process.rs b/crates/ide-run/tests/process.rs index e60ffb00..c9332366 100644 --- a/crates/ide-run/tests/process.rs +++ b/crates/ide-run/tests/process.rs @@ -414,3 +414,28 @@ fn long_java_classpath_is_shortened() { .unwrap_or_else(|| panic!("no pathing jar / argfile in {first}")); assert!(!Path::new(file).exists(), "{file} not deleted"); } + +/// A build tool (here a fake `mvn` that forks like `spring-boot:run`) gets the console +/// encoding in `JAVA_TOOL_OPTIONS`, and the notice its JVMs print about it is hidden. +#[test] +fn build_tools_get_the_console_encoding_for_every_jvm() { + use std::os::unix::fs::PermissionsExt; + let h = harness(); + let mvn = h.root.path().join("mvn"); + let script = "#!/bin/sh\nnotice() { echo \"Picked up JAVA_TOOL_OPTIONS: $JAVA_TOOL_OPTIONS\" >&2; }\n\ + notice; echo \"maven $JAVA_TOOL_OPTIONS\"; notice 2>&1; printf 'Consume Time\\243\\272 1 ms\\n' >&2\n"; + std::fs::write(&mvn, script).unwrap(); + std::fs::set_permissions(&mvn, std::fs::Permissions::from_mode(0o755)).unwrap(); + let chunks: Chunks = Arc::default(); + let sink = chunks.clone(); + let options = StartOptions { encoding: Some("GBK".into()) }; + let config = cfg("t:mvn", mvn.to_str().unwrap(), &["spring-boot:run"]); + let info = + h.pm.start(h.root.path(), &config, &options, h.sink.clone(), Box::new(move |c| sink.lock().push(c))).unwrap(); + wait_exit(&h, info.id, Duration::from_secs(10)); + assert_eq!( + text(&chunks, stdout), + "maven -Dstdout.encoding=GBK -Dstderr.encoding=GBK -Dsun.stdout.encoding=GBK -Dsun.stderr.encoding=GBK\n" + ); + assert_eq!(text(&chunks, stderr), "Consume Time: 1 ms\n"); +} diff --git a/src/features/debugger/__tests__/console.test.ts b/src/features/debugger/__tests__/console.test.ts new file mode 100644 index 00000000..84c8f1ee --- /dev/null +++ b/src/features/debugger/__tests__/console.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { appendConsole, clearConsole, useDebug } from "../state"; + +const lines = (session: number) => useDebug.getState().console[session] ?? []; + +afterEach(() => useDebug.setState({ console: {} })); + +describe("debug console", () => { + it("renders ANSI colours instead of showing the escapes", () => { + // A logback pattern whose colour spans the line break (`%boldMagenta(%logger{36}%n)`), with CRLF. + appendConsole( + 5, + "stdout", + "\x1b[36m2026-10-29 09:13:55\x1b[0;39m \x1b[32m[main]\x1b[0;39m \x1b[1;35mo.d.c.p.l.MessageTopicListener\r\n", + ); + appendConsole(5, "stdout", "\x1b[0;39m - 初始化消息主题订阅监听器成功\r\n"); + const [first, second] = lines(5); + expect(first!.text).toBe("2026-10-29 09:13:55 [main] o.d.c.p.l.MessageTopicListener\n"); + expect(first!.spans[0]).toEqual({ text: "2026-10-29 09:13:55", fg: "var(--ansi-cyan)" }); + expect(first!.spans).toContainEqual({ text: "[main]", fg: "var(--ansi-green)" }); + expect(first!.spans.at(-1)).toEqual({ + text: "o.d.c.p.l.MessageTopicListener\n", + fg: "var(--ansi-magenta)", + bold: true, + }); + expect(second!.text).toBe(" - 初始化消息主题订阅监听器成功\n"); + expect(second!.spans).toEqual([{ text: " - 初始化消息主题订阅监听器成功\n" }]); + expect(lines(5).some((l) => l.text.includes("\x1b"))).toBe(false); + }); + + it("keeps a colour across output events and an escape split between them", () => { + appendConsole(6, "stderr", "\x1b[3"); + appendConsole(6, "stderr", "1mred "); + appendConsole(6, "stderr", "still\x1b[0m plain\n"); + const [line] = lines(6); + expect(line!.text).toBe("red still plain\n"); + expect(line!.spans).toEqual([{ text: "red still", fg: "var(--ansi-red)" }, { text: " plain\n" }]); + // Streams of other categories and sessions have their own state. + appendConsole(6, "stdout", "\x1b[32mgreen"); + appendConsole(7, "stdout", "plain\n"); + expect(lines(7)[0]!.spans).toEqual([{ text: "plain\n" }]); + clearConsole(6); + appendConsole(6, "stdout", "after clear\n"); + expect(lines(6)[0]!.spans).toEqual([{ text: "after clear\n" }]); + }); +}); diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index f35a50e9..32f34de0 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -5,6 +5,7 @@ import { workflowSettings } from "@/platform/workflowSettings"; import { executeAction } from "@/platform/actions"; import { create } from "zustand"; +import { AnsiStream, appendSpans, type Span } from "@/ui/ansi"; import { setContext } from "@/platform/context"; import { call, @@ -55,7 +56,10 @@ export interface DebugSession { export interface ConsoleLine { id: number; category: string; + /** Plain text without escape sequences. */ text: string; + /** `text` styled by its ANSI colours. */ + spans: Span[]; } export interface WatchResult { @@ -95,6 +99,16 @@ export const useExecutionPoint = create(() => ({ path: null, lin const MAX_CONSOLE_LINES = 5000; let lineSeq = 0; +/** ANSI state per session and output category (a colour can span several output events). */ +const ansiStreams = new Map>(); + +function ansiStream(session: DebugSessionId, category: string): AnsiStream { + let streams = ansiStreams.get(session); + if (!streams) ansiStreams.set(session, (streams = new Map())); + let stream = streams.get(category); + if (!stream) streams.set(category, (stream = new AnsiStream())); + return stream; +} // --- accessors --------------------------------------------------------------- @@ -215,7 +229,10 @@ export function publishContext() { // --- console ----------------------------------------------------------------- -export function appendConsole(session: DebugSessionId, category: string, text: string) { +export function appendConsole(session: DebugSessionId, category: string, raw: string) { + if (!raw) return; + const spans = ansiStream(session, category).parse(raw); + const text = spans.map((sp) => sp.text).join(""); if (!text) return; useDebug.setState((s) => { const prev = s.console[session] ?? []; @@ -223,9 +240,9 @@ export function appendConsole(session: DebugSessionId, category: string, text: s const last = next[next.length - 1]; // Merge partial lines of the same category. if (last && last.category === category && !last.text.endsWith("\n")) { - next[next.length - 1] = { ...last, text: last.text + text }; + next[next.length - 1] = { ...last, text: last.text + text, spans: appendSpans(last.spans, spans) }; } else { - next.push({ id: ++lineSeq, category, text }); + next.push({ id: ++lineSeq, category, text, spans }); } if (next.length > MAX_CONSOLE_LINES) next.splice(0, next.length - MAX_CONSOLE_LINES); return { console: { ...s.console, [session]: next } }; @@ -233,6 +250,7 @@ export function appendConsole(session: DebugSessionId, category: string, text: s } export function clearConsole(session: DebugSessionId) { + ansiStreams.delete(session); useDebug.setState((s) => ({ console: { ...s.console, [session]: [] } })); } @@ -255,6 +273,7 @@ export async function startSession( useDebug.setState((s) => { const sessions = Object.fromEntries(Object.entries(s.sessions).filter(([, v]) => isLive(v))); const consoleLines = Object.fromEntries(Object.entries(s.console).filter(([k]) => sessions[+k])); + for (const id of ansiStreams.keys()) if (!sessions[id]) ansiStreams.delete(id); return { sessions, console: consoleLines, active: s.active != null && sessions[s.active] ? s.active : null }; }); try { @@ -657,6 +676,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { export function resetSessions() { const { sessions } = useDebug.getState(); for (const s of Object.values(sessions)) if (s.parent == null && isLive(s)) void stopSession(s.id); + ansiStreams.clear(); useDebug.setState({ sessions: {}, active: null, console: {}, stopSeq: 0 }); useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} }); publishContext(); diff --git a/src/features/debugger/ui/DebugToolWindow.tsx b/src/features/debugger/ui/DebugToolWindow.tsx index 89136337..681c3ea4 100644 --- a/src/features/debugger/ui/DebugToolWindow.tsx +++ b/src/features/debugger/ui/DebugToolWindow.tsx @@ -42,6 +42,8 @@ import { VariablesView, type VarRoot } from "./VariablesView"; import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory"; import { sortVariables } from "../values"; import { workflowSettings } from "@/platform/workflowSettings"; +import { spanStyle } from "@/ui/ansi"; +import ansi from "@/ui/ansi.module.css"; import styles from "./DebugToolWindow.module.css"; import { tx } from "@/platform/i18n"; @@ -609,7 +611,7 @@ function ConsoleView({ session }: { session: DebugSession }) {
{ const el = e.currentTarget; stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24; @@ -624,7 +626,11 @@ function ConsoleView({ session }: { session: DebugSession }) { > {(lines ?? []).map((l) => ( - {l.text} + {l.spans.map((sp, i) => ( + + {sp.text} + + ))} ))}
diff --git a/src/features/run/ConsoleView.tsx b/src/features/run/ConsoleView.tsx index 5322b114..2cfc832b 100644 --- a/src/features/run/ConsoleView.tsx +++ b/src/features/run/ConsoleView.tsx @@ -6,7 +6,6 @@ import { useRef, useState, type ClipboardEvent, - type CSSProperties, type KeyboardEvent, type MouseEvent, } from "react"; @@ -14,7 +13,9 @@ import clsx from "clsx"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useWorkflowSettings } from "@/platform/workflowSettings"; import { Icon, showContextMenu } from "@/ui"; -import type { ConsoleLine, ConsoleModel, SpanStyle } from "./console"; +import { spanStyle } from "@/ui/ansi"; +import ansi from "@/ui/ansi.module.css"; +import type { ConsoleLine, ConsoleModel } from "./console"; import { findOutputLinks, type OutputLink } from "./links"; import { openTarget, openUrl } from "./navigate"; import { segmentLine } from "./segments"; @@ -23,19 +24,6 @@ import { tx } from "@/platform/i18n"; const ROW = 20; -function spanStyle(s: SpanStyle): CSSProperties | undefined { - if (!s.fg && !s.bg && !s.bold && !s.dim && !s.italic && !s.underline && !s.strike) return undefined; - const deco = [s.underline && "underline", s.strike && "line-through"].filter(Boolean).join(" "); - return { - color: s.fg, - background: s.bg, - fontWeight: s.bold ? "var(--fw-code-bold, 700)" : undefined, - opacity: s.dim ? 0.6 : undefined, - fontStyle: s.italic ? "italic" : undefined, - textDecoration: deco || undefined, - }; -} - const Line = memo( function Line({ line, cwd }: { line: ConsoleLine; length: number; cwd: string }) { // Not memoised: the open (last) line mutates in place; `memo` below re-renders on length changes only. @@ -289,7 +277,7 @@ export function ConsoleView({ return (
= 8; - return `var(--ansi-${bright ? "bright-" : ""}${NAMED[n % 8]})`; - } - if (n >= 232) { - const v = 8 + (n - 232) * 10; - return `rgb(${v}, ${v}, ${v})`; - } - const i = n - 16; - const steps = [0, 95, 135, 175, 215, 255]; - return `rgb(${steps[Math.floor(i / 36)]}, ${steps[Math.floor(i / 6) % 6]}, ${steps[i % 6]})`; -} - -function cssColor(cls: string | null, truecolor: string | null): string | undefined { - if (!cls) return undefined; - if (cls === "ansi-truecolor") return truecolor ? `rgb(${truecolor})` : undefined; - const palette = /^ansi-palette-(\d+)$/.exec(cls); - if (palette) return paletteColor(Number(palette[1])); - if (cls.startsWith("ansi-")) return `var(--${cls})`; - return undefined; -} - -function sameStyle(a: SpanStyle, b: SpanStyle) { - return ( - a.fg === b.fg && - a.bg === b.bg && - !!a.bold === !!b.bold && - !!a.dim === !!b.dim && - !!a.italic === !!b.italic && - !!a.underline === !!b.underline && - !!a.strike === !!b.strike - ); -} - -// OSC (…BEL or …ST), and escape sequences other than CSI. -const OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g; -const NON_CSI_ESC_RE = /\x1b(?:[()][0-9A-Za-z]|[=>78DEHMNOZc])/g; -// CSI that isn't SGR: erase-line becomes a carriage return, everything else is dropped. -const CSI_RE = /\x1b\[([0-9;?]*)([ -/]*)([@-~])/g; -// An escape sequence cut off at the end of a chunk. -const INCOMPLETE_RE = /\x1b(?:\[[0-9;?]*[ -/]*|\][^\x07\x1b]*\x1b?)?$/; - -class StreamParser { - private anser = new Anser(); - private carry = ""; - - /** Normalises escapes; returns text whose only escapes are SGR (`ESC[…m`). */ - clean(text: string): string { - let s = this.carry + text; - this.carry = ""; - const cut = INCOMPLETE_RE.exec(s); - if (cut && cut[0].length > 0) { - this.carry = cut[0]; - s = s.slice(0, cut.index); - } - return s - .replace(OSC_RE, "") - .replace(NON_CSI_ESC_RE, "") - .replace(CSI_RE, (all, params: string, _i: string, final: string) => { - if (final === "m") return all; - if (final === "K" && (params === "2" || params === "1")) return "\r"; - if (final === "G" && (params === "" || params === "1" || params === "0")) return "\r"; - return ""; - }) - .replace(/[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f]/g, ""); - } - - /** Style in effect at the end of the previous call (anser only carries it into escaped chunks). */ - private carryStyle: SpanStyle = {}; - - spans(text: string): Span[] { - const out: Span[] = []; - const entries = this.anser.ansiToJson(text, { json: true, use_classes: true }); - entries.forEach((e, i) => { - // Entry 0 is the text before the first escape: it continues the previous style. - const style = i === 0 ? this.carryStyle : entryStyle(e); - if (i === entries.length - 1) this.carryStyle = style; - if (e.content) out.push({ text: e.content, ...style }); - }); - return out; - } -} - -function entryStyle(e: Anser.AnserJsonEntry): SpanStyle { - const decorations = e.decorations ?? []; - const style: SpanStyle = {}; - let fg = cssColor(e.fg, e.fg_truecolor); - let bg = cssColor(e.bg, e.bg_truecolor); - if ((e as { isInverted?: boolean }).isInverted) { - [fg, bg] = [bg ?? "var(--bg-app)", fg ?? "var(--fg)"]; - } - if (fg) style.fg = fg; - if (bg) style.bg = bg; - if (decorations.includes("bold")) style.bold = true; - if (decorations.includes("dim")) style.dim = true; - if (decorations.includes("italic")) style.italic = true; - if (decorations.includes("underline")) style.underline = true; - if (decorations.includes("strikethrough")) style.strike = true; - return style; -} - export interface ConsoleModelOptions { maxLines?: number; /** Cycle buffer: total characters kept (Settings › Console); unlimited when absent. */ @@ -157,7 +38,7 @@ export class ConsoleModel { private nextId = 1; private open: ConsoleLine | null = null; private pendingCR = false; - private readonly parsers = { stdout: new StreamParser(), stderr: new StreamParser(), system: new StreamParser() }; + private readonly parsers = { stdout: new AnsiStream(), stderr: new AnsiStream(), system: new AnsiStream() }; private readonly maxLines: number; private maxChars: number | null; /** Characters currently buffered (line text + one per line break). */ diff --git a/src/ui/ansi.module.css b/src/ui/ansi.module.css new file mode 100644 index 00000000..b9ec6c2c --- /dev/null +++ b/src/ui/ansi.module.css @@ -0,0 +1,19 @@ +/* ANSI palette of the Run and Debug consoles, derived from tokens (spans reference these). */ +.palette { + --ansi-black: var(--control-off); + --ansi-red: var(--danger); + --ansi-green: var(--success); + --ansi-yellow: var(--warning); + --ansi-blue: var(--syn-keyword); + --ansi-magenta: var(--syn-annotation); + --ansi-cyan: color-mix(in srgb, var(--syn-keyword) 50%, var(--syn-string)); + --ansi-white: var(--fg); + --ansi-bright-black: var(--fg-subtle); + --ansi-bright-red: color-mix(in srgb, var(--danger) 72%, var(--fg-strong)); + --ansi-bright-green: color-mix(in srgb, var(--success) 72%, var(--fg-strong)); + --ansi-bright-yellow: color-mix(in srgb, var(--warning) 72%, var(--fg-strong)); + --ansi-bright-blue: color-mix(in srgb, var(--syn-keyword) 72%, var(--fg-strong)); + --ansi-bright-magenta: color-mix(in srgb, var(--syn-annotation) 72%, var(--fg-strong)); + --ansi-bright-cyan: color-mix(in srgb, var(--ansi-cyan) 72%, var(--fg-strong)); + --ansi-bright-white: var(--fg-strong); +} diff --git a/src/ui/ansi.ts b/src/ui/ansi.ts new file mode 100644 index 00000000..5e26437f --- /dev/null +++ b/src/ui/ansi.ts @@ -0,0 +1,160 @@ +/** + * ANSI escape handling for process output (Run and Debug consoles): one stateful + * parser per stream turns text with SGR colours into styled spans, keeping a style + * across chunks and dropping the other escapes. Colours are the `--ansi-*` palette + * (`ansi.module.css`). + */ +/* eslint-disable no-control-regex -- escape-sequence parsing matches control characters by design */ +import type { CSSProperties } from "react"; +import Anser from "anser"; + +export interface SpanStyle { + /** CSS colour (`var(--ansi-red)`, `rgb(…)`). */ + fg?: string; + bg?: string; + bold?: boolean; + dim?: boolean; + italic?: boolean; + underline?: boolean; + strike?: boolean; +} + +export interface Span extends SpanStyle { + text: string; +} + +const NAMED = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]; + +/** xterm 256-colour palette entry (16–255) as `rgb(…)`. */ +export function paletteColor(n: number): string { + if (n < 16) { + const bright = n >= 8; + return `var(--ansi-${bright ? "bright-" : ""}${NAMED[n % 8]})`; + } + if (n >= 232) { + const v = 8 + (n - 232) * 10; + return `rgb(${v}, ${v}, ${v})`; + } + const i = n - 16; + const steps = [0, 95, 135, 175, 215, 255]; + return `rgb(${steps[Math.floor(i / 36)]}, ${steps[Math.floor(i / 6) % 6]}, ${steps[i % 6]})`; +} + +function cssColor(cls: string | null, truecolor: string | null): string | undefined { + if (!cls) return undefined; + if (cls === "ansi-truecolor") return truecolor ? `rgb(${truecolor})` : undefined; + const palette = /^ansi-palette-(\d+)$/.exec(cls); + if (palette) return paletteColor(Number(palette[1])); + if (cls.startsWith("ansi-")) return `var(--${cls})`; + return undefined; +} + +export function sameStyle(a: SpanStyle, b: SpanStyle) { + return ( + a.fg === b.fg && + a.bg === b.bg && + !!a.bold === !!b.bold && + !!a.dim === !!b.dim && + !!a.italic === !!b.italic && + !!a.underline === !!b.underline && + !!a.strike === !!b.strike + ); +} + +/** Inline style of a span (none for plain text). */ +export function spanStyle(s: SpanStyle): CSSProperties | undefined { + if (!s.fg && !s.bg && !s.bold && !s.dim && !s.italic && !s.underline && !s.strike) return undefined; + const deco = [s.underline && "underline", s.strike && "line-through"].filter(Boolean).join(" "); + return { + color: s.fg, + background: s.bg, + fontWeight: s.bold ? "var(--fw-code-bold, 700)" : undefined, + opacity: s.dim ? 0.6 : undefined, + fontStyle: s.italic ? "italic" : undefined, + textDecoration: deco || undefined, + }; +} + +// OSC (…BEL or …ST), and escape sequences other than CSI. +const OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g; +const NON_CSI_ESC_RE = /\x1b(?:[()][0-9A-Za-z]|[=>78DEHMNOZc])/g; +// CSI that isn't SGR: erase-line becomes a carriage return, everything else is dropped. +const CSI_RE = /\x1b\[([0-9;?]*)([ -/]*)([@-~])/g; +// An escape sequence cut off at the end of a chunk. +const INCOMPLETE_RE = /\x1b(?:\[[0-9;?]*[ -/]*|\][^\x07\x1b]*\x1b?)?$/; + +function entryStyle(e: Anser.AnserJsonEntry): SpanStyle { + const decorations = e.decorations ?? []; + const style: SpanStyle = {}; + let fg = cssColor(e.fg, e.fg_truecolor); + let bg = cssColor(e.bg, e.bg_truecolor); + if ((e as { isInverted?: boolean }).isInverted) { + [fg, bg] = [bg ?? "var(--bg-app)", fg ?? "var(--fg)"]; + } + if (fg) style.fg = fg; + if (bg) style.bg = bg; + if (decorations.includes("bold")) style.bold = true; + if (decorations.includes("dim")) style.dim = true; + if (decorations.includes("italic")) style.italic = true; + if (decorations.includes("underline")) style.underline = true; + if (decorations.includes("strikethrough")) style.strike = true; + return style; +} + +/** The ANSI state of one output stream. */ +export class AnsiStream { + private anser = new Anser(); + private carry = ""; + /** Style in effect at the end of the previous call (anser only carries it into escaped chunks). */ + private carryStyle: SpanStyle = {}; + + /** Normalises escapes; returns text whose only escapes are SGR (`ESC[…m`). */ + clean(text: string): string { + let s = this.carry + text; + this.carry = ""; + const cut = INCOMPLETE_RE.exec(s); + if (cut && cut[0].length > 0) { + this.carry = cut[0]; + s = s.slice(0, cut.index); + } + return s + .replace(OSC_RE, "") + .replace(NON_CSI_ESC_RE, "") + .replace(CSI_RE, (all, params: string, _i: string, final: string) => { + if (final === "m") return all; + if (final === "K" && (params === "2" || params === "1")) return "\r"; + if (final === "G" && (params === "" || params === "1" || params === "0")) return "\r"; + return ""; + }) + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f]/g, ""); + } + + /** Spans of text already passed through [`clean`]. */ + spans(text: string): Span[] { + const out: Span[] = []; + const entries = this.anser.ansiToJson(text, { json: true, use_classes: true }); + entries.forEach((e, i) => { + // Entry 0 is the text before the first escape: it continues the previous style. + const style = i === 0 ? this.carryStyle : entryStyle(e); + if (i === entries.length - 1) this.carryStyle = style; + if (e.content) out.push({ text: e.content, ...style }); + }); + return out; + } + + /** [`clean`] then [`spans`], with `\r` dropped: for consoles that don't overwrite lines. */ + parse(text: string): Span[] { + return this.spans(this.clean(text).replace(/\r(?=\n|$)/g, "")); + } +} + +/** Appends `spans` to `into`, merging a span into the previous one of the same style. */ +export function appendSpans(into: Span[], spans: readonly Span[]): Span[] { + const out = into.slice(); + for (const span of spans) { + const prev = out[out.length - 1]; + if (prev && sameStyle(prev, span)) out[out.length - 1] = { ...prev, text: prev.text + span.text }; + else out.push(span); + } + return out; +}