diff --git a/crates/ide-core/src/output_encoding.rs b/crates/ide-core/src/output_encoding.rs index 7bf0446d1..cdd513c97 100644 --- a/crates/ide-core/src/output_encoding.rs +++ b/crates/ide-core/src/output_encoding.rs @@ -76,7 +76,8 @@ fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String { text } -fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> { +/// The `encoding_rs` encoding of a Windows code page, when it has one. +pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> { Some(match cp { 65001 => encoding_rs::UTF_8, 936 => encoding_rs::GBK, @@ -101,6 +102,56 @@ fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> { }) } +/// The code page Windows console programs write to a pipe in: the OEM code page (GBK on a +/// Chinese system, Shift_JIS on a Japanese one, EUC-KR on a Korean one, 437 / 850 / 866… +/// elsewhere). `None` when it is UTF-8 (Windows' "Use Unicode UTF-8" option) or off Windows. +pub fn console_code_page() -> Option { + #[cfg(windows)] + { + // SAFETY: GetOEMCP is an argument-less query with no preconditions. + let cp = unsafe { windows_sys::Win32::Globalization::GetOEMCP() }; + (cp != 65001).then_some(cp) + } + #[cfg(not(windows))] + { + None + } +} + +/// Whether every character of code page `cp` is one byte, so text in it can be decoded +/// piece by piece. +#[cfg(windows)] +pub fn is_single_byte_code_page(cp: u32) -> bool { + use windows_sys::Win32::Globalization::{CPINFO, GetCPInfo}; + // SAFETY: CPINFO is plain data, which GetCPInfo fills in. + unsafe { + let mut info: CPINFO = std::mem::zeroed(); + GetCPInfo(cp, &mut info) != 0 && info.MaxCharSize == 1 + } +} + +/// `bytes` in code page `cp`, decoded by Windows (for the code pages `encoding_rs` lacks, +/// like the OEM pages 437, 850 or 852). +#[cfg(windows)] +pub fn decode_code_page(bytes: &[u8], cp: u32) -> String { + use windows_sys::Win32::Globalization::MultiByteToWideChar; + let Ok(len) = i32::try_from(bytes.len()) else { + return String::from_utf8_lossy(bytes).into_owned(); + }; + if len == 0 { + return String::new(); + } + // SAFETY: `bytes` holds `len` bytes; no output buffer asks for the length needed. + let needed = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, std::ptr::null_mut(), 0) }; + if needed <= 0 { + return String::from_utf8_lossy(bytes).into_owned(); + } + let mut wide = vec![0u16; needed as usize]; + // SAFETY: `wide` holds `needed` units. + let written = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, wide.as_mut_ptr(), needed) }; + String::from_utf16_lossy(&wide[..written.max(0) as usize]) +} + 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"; diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 65f35c79b..17a5915c5 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -498,7 +498,8 @@ impl ProcessManager { None, )?; } - spawn_pump(rx, on_output.clone(), entry.clone(), sink, options.encoding.clone(), notice)?; + let decoder = || OutputDecoder::for_pipes(options.encoding.as_deref()); + spawn_pump(rx, on_output.clone(), entry.clone(), sink, [decoder(), decoder()], notice)?; Ok(()) })(); if let Err(e) = result { @@ -629,7 +630,10 @@ impl ProcessManager { #[cfg(unix)] Some(Box::new(wait_readable)), ) - .and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), None)); + .and_then(|_| { + let decoder = || OutputDecoder::for_label(options.encoding.as_deref()); + spawn_pump(rx, on_output, entry.clone(), sink, [decoder(), decoder()], None) + }); if let Err(e) = result { let _ = child.kill(); let _ = child.wait(); @@ -1120,18 +1124,35 @@ fn chunk(stream: Stream, text: String) -> OutputChunk { } } +/// Appends what `decode` writes to `pending`, through the notice filter when there is one. +fn emit( + decode: impl FnOnce(&mut String), + notice: &mut Option, + decoded: &mut String, + pending: &mut String, +) { + match notice { + Some(filter) => { + decode(decoded); + filter.filter(decoded, pending); + decoded.clear(); + } + None => decode(pending), + } +} + /// Merges both streams in arrival order, batching consecutive output of the same stream. +/// `decoders` decode stdout and stderr. fn spawn_pump( rx: mpsc::Receiver, on_output: OutputFn, entry: Arc, sink: SharedSink, - encoding: Option, + decoders: [OutputDecoder; 2], 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()); + let [mut out_dec, mut err_dec] = decoders; // "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); @@ -1140,6 +1161,8 @@ fn spawn_pump( let mut pending = String::new(); let mut pending_stream = Stream::Stdout; let mut batch_started: Option = None; + // Since when a decoder holds part of a line back to tell its encoding. + let mut held_since: Option = None; let mut status: Option = None; let mut drain_deadline: Option = None; @@ -1154,6 +1177,7 @@ fn spawn_pump( let now = Instant::now(); let wait = [ batch_started.map(|t| (t + BATCH_INTERVAL).saturating_duration_since(now)), + held_since.map(|t| (t + BATCH_INTERVAL).saturating_duration_since(now)), drain_deadline.map(|t| t.saturating_duration_since(now)), ] .into_iter() @@ -1170,14 +1194,8 @@ fn spawn_pump( 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), - } + emit(|out| dec.decode_into(&bytes, out), notice, &mut decoded, &mut pending); + held_since = if out_dec.holds() || err_dec.holds() { held_since.or(Some(now)) } else { None }; if !pending.is_empty() && batch_started.is_none() { batch_started = Some(Instant::now()); } @@ -1193,6 +1211,25 @@ fn spawn_pump( if drain_deadline.is_some_and(|d| Instant::now() >= d) { break; } + // Nothing more came: show a held prompt. + if held_since.is_some_and(|t| t.elapsed() >= BATCH_INTERVAL) { + held_since = None; + for (stream, dec, notice) in [ + (Stream::Stdout, &mut out_dec, &mut out_notice), + (Stream::Stderr, &mut err_dec, &mut err_notice), + ] { + if dec.holds() { + if stream != pending_stream { + flush(&mut pending, pending_stream, &mut batch_started); + pending_stream = stream; + } + emit(|out| dec.release(out), notice, &mut decoded, &mut pending); + } + } + if !pending.is_empty() && batch_started.is_none() { + batch_started = Some(Instant::now()); + } + } if batch_started.is_some_and(|t| t.elapsed() >= BATCH_INTERVAL) { flush(&mut pending, pending_stream, &mut batch_started); } diff --git a/crates/ide-run/src/utf8.rs b/crates/ide-run/src/utf8.rs index e8fa139f6..e05cefa14 100644 --- a/crates/ide-run/src/utf8.rs +++ b/crates/ide-run/src/utf8.rs @@ -136,6 +136,7 @@ mod tests { pub enum OutputDecoder { Utf8(Utf8Decoder), Other(Box), + Mixed(Box), } impl OutputDecoder { @@ -147,10 +148,36 @@ impl OutputDecoder { } } + /// [`Self::for_label`] for output read from pipes. On Windows a UTF-8 console also decodes + /// what Windows' own programs write: the lines that are not UTF-8 in the system's console + /// code page (`ping`, `dir`, `cmd`'s messages), whatever the system's language, and UTF-16 + /// (`wsl.exe`, `cmd /u`). + pub fn for_pipes(label: Option<&str>) -> Self { + match Self::for_label(label) { + Self::Utf8(_) if cfg!(windows) => Self::Mixed(Box::new(MixedDecoder::new( + ide_core::output_encoding::console_code_page().and_then(NativeDecoder::for_code_page), + ))), + other => other, + } + } + pub fn decode_into(&mut self, bytes: &[u8], out: &mut String) { match self { Self::Utf8(d) => d.decode_into(bytes, out), Self::Other(d) => decode_with(d, bytes, out, false), + Self::Mixed(d) => d.decode_into(bytes, out), + } + } + + /// Whether part of a line is held back until it shows its encoding. + pub fn holds(&self) -> bool { + matches!(self, Self::Mixed(d) if d.holds()) + } + + /// Decodes the held part of a line now (no more output came for a while). + pub fn release(&mut self, out: &mut String) { + if let Self::Mixed(d) = self { + d.release(out); } } @@ -162,10 +189,218 @@ impl OutputDecoder { decode_with(d, &[], &mut out, true); out } + Self::Mixed(d) => d.finish(), } } } +/// Decoder for a Windows code page: `encoding_rs`'s, or Windows' own for the single-byte +/// pages `encoding_rs` lacks (the OEM pages 437, 850, 852…). +pub enum NativeDecoder { + Encoding(Box), + #[cfg(windows)] + SingleByte(u32), +} + +impl NativeDecoder { + /// `None` for UTF-8 and for code pages neither can decode piece by piece. + pub fn for_code_page(cp: u32) -> Option { + if let Some(enc) = ide_core::output_encoding::encoding_for_code_page(cp) { + return (enc != encoding_rs::UTF_8) + .then(|| Self::Encoding(Box::new(enc.new_decoder_without_bom_handling()))); + } + #[cfg(windows)] + if ide_core::output_encoding::is_single_byte_code_page(cp) { + return Some(Self::SingleByte(cp)); + } + None + } + + fn decode_into(&mut self, bytes: &[u8], out: &mut String, last: bool) { + match self { + Self::Encoding(d) => decode_with(d, bytes, out, last), + #[cfg(windows)] + Self::SingleByte(cp) => out.push_str(&ide_core::output_encoding::decode_code_page(bytes, *cp)), + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Line { + /// Only ASCII so far, which reads the same in both. + Open, + Utf8, + Native, +} + +/// UTF-8, the system code page for the lines that are not UTF-8, and UTF-16: on pipes, cargo, +/// git or node write UTF-8, Windows' own programs the console code page (GBK, Shift_JIS, +/// EUC-KR, 866…) and a few of them (`wsl.exe`, `cmd /u`) UTF-16LE, even within one command. +/// +/// Each line is decoded as UTF-8 or the code page (UTF-8 with replacement characters without +/// one). The part of a line after its first non-ASCII byte is held until the line ends or +/// turns out not to be UTF-8; [`Self::release`] decodes a held prompt when no more output comes. +/// +/// A read with NUL bytes, which text in the others never has, is UTF-16LE (ASCII characters +/// and line ends in it have them); UTF-16 goes on until one of its lines has ended and a read +/// without NUL bytes comes. +pub struct MixedDecoder { + utf8: Utf8Decoder, + native: Option, + line: Line, + held: Vec, + utf16: Option, +} + +impl MixedDecoder { + pub fn new(native: Option) -> Self { + Self { utf8: Utf8Decoder::new(), native, line: Line::Open, held: Vec::new(), utf16: None } + } + + pub fn decode_into(&mut self, bytes: &[u8], out: &mut String) { + if bytes.is_empty() { + return; + } + let nul = bytes.contains(&0); + match self.utf16.as_ref().map(|u| u.open || nul) { + Some(true) => { + if let Some(u) = &mut self.utf16 { + u.decode(bytes, out); + } + return; + } + Some(false) => self.utf16 = None, + None if nul => { + self.end_line(out); + let mut u = Utf16::new(); + u.decode(utf16_aligned(bytes), out); + self.utf16 = Some(u); + return; + } + None => {} + } + for piece in bytes.split_inclusive(|b| *b == b'\n') { + let ends = piece.last() == Some(&b'\n'); + match self.line { + Line::Utf8 => self.utf8.decode_into(piece, out), + Line::Native => self.decode_native(piece, out), + Line::Open => { + let mut rest = piece; + if self.held.is_empty() { + let ascii = piece.iter().take_while(|b| b.is_ascii()).count(); + out.push_str(std::str::from_utf8(&piece[..ascii]).unwrap_or_default()); + rest = &piece[ascii..]; + } + self.held.extend_from_slice(rest); + let decided = match std::str::from_utf8(&self.held) { + Err(e) if e.error_len().is_some() => Some(Line::Native), + Ok(_) if ends => Some(Line::Utf8), + _ => None, + }; + if let Some(line) = decided { + self.line = line; + self.flush_held(out); + } + } + } + if ends { + self.line = Line::Open; + } + } + } + + fn flush_held(&mut self, out: &mut String) { + let held = std::mem::take(&mut self.held); + match self.line { + Line::Native => self.decode_native(&held, out), + Line::Open | Line::Utf8 => self.utf8.decode_into(&held, out), + } + } + + fn decode_native(&mut self, bytes: &[u8], out: &mut String) { + match &mut self.native { + Some(native) => native.decode_into(bytes, out, false), + None => self.utf8.decode_into(bytes, out), + } + } + + /// Decodes the rest of the current line as it is. + fn end_line(&mut self, out: &mut String) { + if self.holds() { + self.line = if std::str::from_utf8(&self.held).is_ok() { Line::Utf8 } else { Line::Native }; + self.flush_held(out); + } + out.push_str(&self.utf8.finish()); + self.line = Line::Open; + } + + pub fn holds(&self) -> bool { + !self.held.is_empty() + } + + /// Decodes the held part of the line as UTF-8: held text has been UTF-8 so far. + pub fn release(&mut self, out: &mut String) { + if self.holds() { + self.line = Line::Utf8; + self.flush_held(out); + } + } + + pub fn finish(&mut self) -> String { + let mut out = String::new(); + if let Some(mut u) = self.utf16.take() { + decode_with(&mut u.decoder, &[], &mut out, true); + } + self.end_line(&mut out); + if let Some(native) = &mut self.native { + native.decode_into(&[], &mut out, true); + } + out + } +} + +struct Utf16 { + decoder: encoding_rs::Decoder, + /// Inside a line or a code unit: the next read goes on in UTF-16 whatever it holds. + open: bool, + /// An odd number of bytes so far. + odd: bool, +} + +impl Utf16 { + fn new() -> Self { + Self { decoder: encoding_rs::UTF_16LE.new_decoder_with_bom_removal(), open: false, odd: false } + } + + fn decode(&mut self, bytes: &[u8], out: &mut String) { + let start = out.len(); + decode_with(&mut self.decoder, bytes, out, false); + self.odd ^= bytes.len() % 2 == 1; + if self.odd { + self.open = true; + } else if out.len() > start { + self.open = !out.ends_with('\n'); + } + } +} + +/// `bytes` from the first byte of a UTF-16LE code unit. NUL bytes are mostly the high bytes of +/// ASCII characters, at odd offsets from it; at even ones, the first byte is the high byte of a +/// character whose low byte came before (and was shown as ASCII). +fn utf16_aligned(bytes: &[u8]) -> &[u8] { + let (mut even, mut odd) = (0usize, 0usize); + for (i, b) in bytes.iter().enumerate() { + if *b == 0 { + if i % 2 == 0 { + even += 1; + } else { + odd += 1; + } + } + } + if even > odd { &bytes[1..] } else { bytes } +} + fn decode_with(d: &mut encoding_rs::Decoder, bytes: &[u8], out: &mut String, last: bool) { let mut src = bytes; loop { @@ -180,7 +415,8 @@ fn decode_with(d: &mut encoding_rs::Decoder, bytes: &[u8], out: &mut String, las #[cfg(test)] mod encoding_tests { - use super::OutputDecoder; + use super::{MixedDecoder, NativeDecoder, OutputDecoder}; + use ide_core::output_encoding::encoding_for_code_page; #[test] fn gbk_split_across_chunks() { @@ -202,4 +438,157 @@ mod encoding_tests { assert_eq!(out, "héllo"); assert!(matches!(d, OutputDecoder::Utf8(_))); } + + fn mixed(cp: u32) -> MixedDecoder { + MixedDecoder::new(Some(NativeDecoder::for_code_page(cp).expect("code page"))) + } + + fn encode(cp: u32, text: &str) -> Vec { + let (bytes, _, unmappable) = encoding_for_code_page(cp).expect("code page").encode(text); + assert!(!unmappable, "{text} in {cp}"); + bytes.into_owned() + } + + /// Simplified and Traditional Chinese, Korean, Japanese, Cyrillic (OEM and ANSI), Western. + const SAMPLES: &[(u32, &str)] = &[ + (936, "正在 Ping 127.0.0.1 具有 32 字节的数据:"), + (950, "系統找不到指定的路徑。"), + (949, "지정된 경로를 찾을 수 없습니다."), + (932, "指定されたパスが見つかりません。"), + (866, "Системе не удается найти указанный путь."), + (1251, "Системе не удается найти указанный путь."), + (1252, "Le chemin d'accès spécifié est introuvable."), + ]; + + #[test] + fn utf8_and_code_page_lines_mix_whatever_the_chunks() { + for &(cp, native) in SAMPLES { + let mut bytes = " Compiling 编译 ü\n".as_bytes().to_vec(); + bytes.extend(encode(cp, native)); + bytes.extend_from_slice(b"\r\n"); + bytes.extend_from_slice("ok ✓\n".as_bytes()); + bytes.extend(encode(cp, native)); + let expected = format!(" Compiling 编译 ü\n{native}\r\nok ✓\n{native}"); + for split in 0..=bytes.len() { + let mut d = mixed(cp); + let mut out = String::new(); + d.decode_into(&bytes[..split], &mut out); + d.decode_into(&bytes[split..], &mut out); + out.push_str(&d.finish()); + assert_eq!(out, expected, "code page {cp}, split at {split}"); + } + } + } + + #[test] + fn byte_by_byte_code_page_output() { + for &(cp, native) in SAMPLES { + let bytes = [encode(cp, native), b"\n".to_vec()].concat(); + let mut d = mixed(cp); + let mut out = String::new(); + for b in &bytes { + d.decode_into(std::slice::from_ref(b), &mut out); + } + assert!(!d.holds()); + assert_eq!(out, format!("{native}\n"), "code page {cp}"); + } + } + + #[test] + fn ascii_and_code_page_prompts_show_at_once_and_utf8_ones_on_release() { + let mut d = mixed(936); + let mut out = String::new(); + d.decode_into(b"Password: ", &mut out); + assert_eq!(out, "Password: "); + assert!(!d.holds()); + + out.clear(); + d.decode_into(&encode(936, "请按任意键继续. . ."), &mut out); + assert_eq!(out, "请按任意键继续. . ."); + assert!(!d.holds()); + + let mut d = mixed(936); + out.clear(); + d.decode_into("Continue? 继续 ".as_bytes(), &mut out); + assert_eq!(out, "Continue? "); + assert!(d.holds()); + d.release(&mut out); + assert_eq!(out, "Continue? 继续 "); + d.decode_into("[y/N]\n".as_bytes(), &mut out); + assert_eq!(out, "Continue? 继续 [y/N]\n"); + } + + #[test] + fn a_released_line_split_inside_a_character_completes() { + let mut d = mixed(949); + let mut out = String::new(); + let bytes = "é中".as_bytes(); + d.decode_into(&bytes[..3], &mut out); + d.release(&mut out); + d.decode_into(&bytes[3..], &mut out); + out.push_str(&d.finish()); + assert_eq!(out, "é中"); + } + + #[test] + fn utf8_code_page_has_no_mixed_decoder() { + assert!(NativeDecoder::for_code_page(65001).is_none()); + } + + fn utf16(text: &str) -> Vec { + text.encode_utf16().flat_map(u16::to_le_bytes).collect() + } + + /// What `wsl.exe -l -v` writes, on a Chinese system. + const WSL: &str = " NAME STATE VERSION\r\n* Ubuntu-22.04 Running 2\r\n\ + 适用于 Linux 的 Windows 子系统: 默认\r\n"; + + #[test] + fn utf16_output_between_utf8_and_code_page_lines_whatever_the_chunks() { + let before = " Compiling 编译\n".as_bytes(); + let wsl = utf16(WSL); + let after = [encode(936, "系统找不到指定的路径。"), "\r\ndone ✓\n".as_bytes().to_vec()].concat(); + let expected = format!(" Compiling 编译\n{WSL}系统找不到指定的路径。\r\ndone ✓\n"); + for split in 0..=wsl.len() { + let mut d = mixed(936); + let mut out = String::new(); + for chunk in [before, &wsl[..split], &wsl[split..], &after] { + d.decode_into(chunk, &mut out); + } + out.push_str(&d.finish()); + assert_eq!(out, expected, "split at {split}"); + } + } + + #[test] + fn utf16_with_a_byte_order_mark_or_starting_with_cjk() { + let mut d = mixed(949); + let mut out = String::new(); + d.decode_into(&[&[0xFF, 0xFE][..], &utf16("한국어 OK\r\n")].concat(), &mut out); + d.decode_into(&utf16("中文 OK\r\n"), &mut out); + out.push_str(&d.finish()); + assert_eq!(out, "한국어 OK\r\n中文 OK\r\n"); + } + + #[test] + fn without_a_code_page_other_lines_are_lossy_utf8_and_utf16_still_reads() { + let mut d = MixedDecoder::new(None); + let mut out = String::new(); + d.decode_into(b"caf\xe9\n", &mut out); + d.decode_into(&utf16("wsl 子系统\r\n"), &mut out); + d.decode_into("ok ✓\n".as_bytes(), &mut out); + out.push_str(&d.finish()); + assert_eq!(out, "caf\u{FFFD}\nwsl 子系统\r\nok ✓\n"); + } + + #[cfg(windows)] + #[test] + fn oem_code_pages_without_an_encoding_rs_table_decode_through_windows() { + for cp in [437, 850, 852] { + let mut d = mixed(cp); + let mut out = String::new(); + d.decode_into(b"caf\x82\r\n", &mut out); + assert_eq!(out, "caf\u{e9}\r\n", "code page {cp}"); + } + } } diff --git a/crates/ide-run/tests/windows_output.rs b/crates/ide-run/tests/windows_output.rs new file mode 100644 index 000000000..747e13e7a --- /dev/null +++ b/crates/ide-run/tests/windows_output.rs @@ -0,0 +1,69 @@ +#![cfg(windows)] + +use ide_core::{IdeEvent, SharedSink}; +use ide_run::{OutputChunk, ProcessManager, RunConfig, StartOptions}; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::sync::mpsc; +use std::time::Duration; + +/// Stdout and stderr of a shell command line run on pipes in a UTF-8 console. +fn run(command: &str) -> String { + let (tx, events) = mpsc::channel(); + let tx = Mutex::new(tx); + let sink: SharedSink = Arc::new(move |e: IdeEvent| { + let _ = tx.lock().send(e); + }); + let root = tempfile::tempdir().unwrap(); + let config = RunConfig { + id: "t:out".into(), + name: "t:out".into(), + kind: "shell".into(), + command: command.into(), + args: Vec::new(), + cwd: None, + env: BTreeMap::new(), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: Default::default(), + }; + let chunks: Arc>> = Arc::default(); + let out = chunks.clone(); + let pm = ProcessManager::new(); + let info = pm + .start(root.path(), &config, &StartOptions::default(), sink, Box::new(move |c| out.lock().push(c))) + .expect("start"); + loop { + match events.recv_timeout(Duration::from_secs(20)).expect("RunExited event") { + IdeEvent::RunExited { run, .. } if run == info.id => break, + _ => {} + } + } + chunks + .lock() + .iter() + .filter_map(|c| match c { + OutputChunk::Stdout { text } | OutputChunk::Stderr { text } => Some(text.as_str()), + _ => None, + }) + .collect() +} + +/// `cmd`'s own messages reach the pipe in the console code page (GBK, Shift_JIS, EUC-KR, +/// 437…, by the system's language), and still read right in a UTF-8 console. +#[test] +fn native_messages_in_the_console_code_page_are_decoded() { + let text = run(r"dir C:\openidea-no-such-dir"); + assert!(text.trim().lines().count() >= 2, "{text:?}"); + assert!(!text.contains('\u{FFFD}'), "{text:?}"); +} + +/// `cmd /u` writes UTF-16LE, as `wsl.exe` does; it reads right whatever the system's language. +#[test] +fn utf16_output_is_decoded() { + let text = run("cmd /u /c echo 中文 한국어 OK"); + assert_eq!(text, "中文 한국어 OK\r\n"); +}