mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
fix(run): Give forked and debugged JVMs the console encoding and render colours in the Debug console (#220)
Only the JVM the IDE launched directly was told the console's output encoding, so forked JVMs (spring-boot:run, Surefire, bootRun, Gradle workers), debugger-launched JVMs and Build-window builds wrote GBK on Windows while the console decoded UTF-8. Every JVM the IDE starts on Windows now gets the console encoding: java gets -Dfile.encoding alongside the std* properties, build tools and their forks get JAVA_TOOL_OPTIONS (std* only) with the IDE's own 'Picked up JAVA_TOOL_OPTIONS' line filtered, shell command lines are recognised by their first word, and java-debug launches get the matching vmArgs. The Debug console now shares the Run console's ANSI parser. Encoding helpers moved to ide-core::output_encoding. Not verified on a Windows machine. Fixes #180 Fixes #182 Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
This commit is contained in:
@@ -45,8 +45,12 @@ pub(crate) fn run(
|
||||
mut on_line: impl FnMut(Stream, &str),
|
||||
) -> IdeResult<Output> {
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
pub env: Vec<(String, String)>,
|
||||
/// "Picked up JAVA_TOOL_OPTIONS: …" for a variable set here alone, to drop from the output.
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
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<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
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<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
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<String> {
|
||||
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<String, String> {
|
||||
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<String> = 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");
|
||||
}
|
||||
}
|
||||
@@ -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<DebugSessionId> {
|
||||
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::<serde_json::Value>(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::<serde_json::Value>(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
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<String> {
|
||||
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<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
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<String> = args.iter().map(|a| (*a).to_owned()).collect();
|
||||
let vars: BTreeMap<String, String> = 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())]);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,8 @@ pub(crate) struct Invocation {
|
||||
pub args: Vec<String>,
|
||||
/// 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<String> {
|
||||
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<String>) -> 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<String>) -> 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<Entry>,
|
||||
sink: SharedSink,
|
||||
encoding: Option<String>,
|
||||
notice: Option<String>,
|
||||
) -> 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<Instant> = 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));
|
||||
}
|
||||
|
||||
@@ -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"), "<project><packaging>pom</packaging></project>"),
|
||||
(file("ruoyi-modules/ruoyi-chat/pom.xml"), "<project/>"),
|
||||
(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(&[
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
@@ -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<ExecutionPoint>(() => ({ 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<DebugSessionId, Map<string, AnsiStream>>();
|
||||
|
||||
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();
|
||||
|
||||
@@ -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 }) {
|
||||
<div className={styles.console}>
|
||||
<div
|
||||
ref={scroller}
|
||||
className={styles.consoleOutput}
|
||||
className={clsx(styles.consoleOutput, ansi.palette)}
|
||||
onScroll={(e) => {
|
||||
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) => (
|
||||
<span key={l.id} className={clsx(styles.out, styles[`cat-${l.category}`])}>
|
||||
{l.text}
|
||||
{l.spans.map((sp, i) => (
|
||||
<span key={i} style={spanStyle(sp)}>
|
||||
{sp.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={clsx(styles.console, softWrap && styles.wrap)}
|
||||
className={clsx(styles.console, ansi.palette, softWrap && styles.wrap)}
|
||||
role="log"
|
||||
aria-live="off"
|
||||
aria-label={aria["aria-label"] ?? "Console"}
|
||||
|
||||
@@ -1,24 +1,3 @@
|
||||
/* ANSI palette derived from tokens (console spans reference these). */
|
||||
.root,
|
||||
.console {
|
||||
--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);
|
||||
}
|
||||
|
||||
.root {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
+5
-124
@@ -1,29 +1,15 @@
|
||||
/**
|
||||
* Console line model for run output: splits streamed chunks into lines,
|
||||
* parses ANSI SGR colours (via `anser`, one stateful parser per stream),
|
||||
* parses ANSI SGR colours (`ui/ansi`, one stateful parser per stream),
|
||||
* handles `\r` overwrites (progress bars) and caps the buffer.
|
||||
*/
|
||||
/* eslint-disable no-control-regex -- escape-sequence parsing matches control characters by design */
|
||||
import Anser from "anser";
|
||||
import type { OutputChunk } from "@/platform/ipc";
|
||||
import { AnsiStream, sameStyle, type Span } from "@/ui/ansi";
|
||||
|
||||
export { paletteColor, type Span, type SpanStyle } from "@/ui/ansi";
|
||||
|
||||
export type ChunkKind = OutputChunk["kind"];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ConsoleLine {
|
||||
/** Monotonic id (stable across trimming). */
|
||||
id: number;
|
||||
@@ -37,111 +23,6 @@ export const MAX_LINES = 50_000;
|
||||
/** Very long lines are broken up so a single minified blob can't stall rendering. */
|
||||
export const MAX_LINE_LENGTH = 8_000;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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). */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+160
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user