diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 2f6dda11..8d303a4a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1463,6 +1463,7 @@ version = "0.1.0" dependencies = [ "lithe-protocol", "serde", + "serde_json", ] [[package]] diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index 52b30efd..ededfef3 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -251,6 +251,32 @@ itself says it is a path (separator, `~`, `file://`, provider URI or line suffix file extensions or touches the file system: the host checks existence, owns hit regions, and opens the target. At most 256 texts of 4096 bytes. See `rust/fixtures/terminal/link-target-v1.json`. +## Output styles + +`outputStyle.decode` accepts `{text, state?}` and returns `{lines: Line[], state}`. A host sends each chunk of one +console's output as it arrives, passing back the `state` the previous answer returned (none for the first chunk), and +keeps only plain text plus styled ranges, so copying, search, output links and the IDE API never see an escape. A +`Line` is `{text, spans?}`: the text without its line ending and `{start, length, style}` ranges in UTF-16 code units, +ascending and non-overlapping; text outside every range is plain. A `style` has optional `foreground` and `background` +(`{index: 0-255}` or `{rgb: "#rrggbb"}`) and the flags `bold`, `dim`, `italic`, `underline`, `inverse`, `hidden` and +`strikethrough`, each omitted when unset. `lines` are the lines the chunk ended, oldest first, the first continuing the +previous open line; `state.line` is the line still open, which the host shows after them. The rest of the state (the +style in effect, an unfinished escape sequence and a pending carriage return) is opaque to the host, and a state Core +did not return is `invalid_request`. + +SGR (`ESC [ … m`) sets the style across chunks and lines: 0 resets; 1, 2, 3, 4 (`4:0` off), 7, 8, 9 and 21 set +attributes and 22–29 clear them; 30–37, 90–97, 40–47 and 100–107 pick palette colours 0–15, and 39 and 49 restore the +defaults; 38 and 48 take `5;n` or `2;r;g;b`, also in the colon forms `38:5:n`, `38:2:r:g:b` and `38:2::r:g:b`; 58 is +consumed and ignored, and an invalid colour keeps the current one. `\n` ends a line. `\r` makes the next printed +character replace the line, so `\r\n` is a plain line ending and progress rewrites keep their last state; `ESC [ 2 K`, +and `ESC [ K` right after a carriage return, clear the line; `ESC [ G` (column 1) acts as `\r`. Private (`<=>?`) and +intermediate-byte sequences, every other control sequence, OSC/DCS/SOS/PM/APC strings (up to BEL or `ESC \`, keeping +the text between OSC 8 hyperlink markers), other escapes, and control characters other than tab are dropped. The host +maps palette entries 0–15 to its theme and 16–255 to the xterm cube and grey ramp, swaps colours for `inverse` and +draws `hidden` text in the background colour. A chunk is at most 1 MiB; lines longer than 16 KiB continue on the next +line, and a line keeps at most 512 ranges. Lives in `rust/crates/terminal`; see +`rust/fixtures/terminal/output-style-v1.json`. + The Rust core is the shared application runtime for macOS SwiftUI and Windows React/Tauri. macOS calls the stable C ABI while the Tauri host links the Rust crate directly. The C ABI remains: @@ -567,6 +593,7 @@ or removing a command fails until the table changes in the same commit. | `workspace.visibilityDefaults` | Return the built-in hidden-path lists (from `lithe-core-rules`) | | `workbench.chromeLayout.reduce` | Apply one transition to the rearrangeable workbench chrome: stripe, title bar and status bar button placement, hidden stripe buttons and open tool panels (from `lithe-core-rules`) | | `terminal.parseLinkTarget` | Resolve terminal link candidates into file or external targets | +| `outputStyle.decode` | Decode one chunk of console output with ANSI escapes into plain lines and styled ranges, carrying the console's state to the next chunk | | `debug.createSession` | Create a transport-neutral DAP session and return its initialize frame | | `debug.launch` | Queue a launch or attach request, including during initialization | | `debug.steppingFilters` | Return adapter defaults or normalize portable stepping filters | diff --git a/rust/crates/core/src/protocol/command.rs b/rust/crates/core/src/protocol/command.rs index 784fcf3a..7ae3aa1c 100644 --- a/rust/crates/core/src/protocol/command.rs +++ b/rust/crates/core/src/protocol/command.rs @@ -146,6 +146,9 @@ pub enum CoreCommand { WorkbenchChromeLayoutReduce, /// Resolves terminal link candidates into file or external targets (`terminal.parseLinkTarget`). TerminalParseLinkTarget, + /// Decodes one chunk of console output with ANSI escapes into plain lines and styled ranges + /// (`outputStyle.decode`), from `lithe-terminal`. + OutputStyleDecode, /// Language server sessions, documents, edits, requests and events (`lsp.*`). Lsp(LspCommand), /// Normalizes the stored proxy settings into the policy a network stack @@ -1220,6 +1223,7 @@ impl CoreCommand { } "workbench.chromeLayout.reduce" => Some(Self::WorkbenchChromeLayoutReduce), "terminal.parseLinkTarget" => Some(Self::TerminalParseLinkTarget), + "outputStyle.decode" => Some(Self::OutputStyleDecode), "debug.createSession" => Some(Self::Debug(DebugCommand::CreateSession)), "debug.launch" => Some(Self::Debug(DebugCommand::Launch)), "debug.steppingFilters" => Some(Self::Debug(DebugCommand::SteppingFilters)), @@ -1677,6 +1681,7 @@ mod tests { "debug.decodeBackendConfiguration", "outputLinks.match", "outputLinks.decodeResolution", + "outputStyle.decode", ] { assert!(CoreCommand::parse(command).is_some(), "missing {command}"); } diff --git a/rust/crates/core/src/runtime/dispatcher.rs b/rust/crates/core/src/runtime/dispatcher.rs index 12044438..7ae39b2e 100644 --- a/rust/crates/core/src/runtime/dispatcher.rs +++ b/rust/crates/core/src/runtime/dispatcher.rs @@ -461,6 +461,22 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::OutputStyleDecode => { + let result = + serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid output style request") + .with_details(error.to_string()) + }) + .and_then(lithe_terminal::decode_output_style); + match result { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("output style result should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::MarkdownRender => { match serde_json::from_value::(parsed.payload).map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid Markdown render request") diff --git a/rust/crates/core/src/tests/protocol.rs b/rust/crates/core/src/tests/protocol.rs index 3f4ab24b..7f451191 100644 --- a/rust/crates/core/src/tests/protocol.rs +++ b/rust/crates/core/src/tests/protocol.rs @@ -540,3 +540,38 @@ fn terminal_link_fixtures_cross_the_json_boundary() { ); } } + +#[test] +fn output_style_fixtures_cross_the_json_boundary() { + // Each case replays its chunks with the state the previous answer returned, as a host does. + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/terminal/output-style-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let mut lines = Vec::new(); + let mut state = Value::Null; + for chunk in case["chunks"].as_array().unwrap() { + let mut request = serde_json::json!({ "text": chunk }); + if !state.is_null() { + request["state"] = state; + } + let result = call_fixture_command("outputStyle.decode", request); + assert_eq!(result["ok"], true, "{}: {result}", case["name"]); + lines.extend(result["data"]["lines"].as_array().unwrap().iter().cloned()); + state = result["data"]["state"].clone(); + } + assert_eq!(Value::Array(lines), case["lines"], "{}", case["name"]); + assert_eq!(state["line"], case["open"], "{}", case["name"]); + } +} + +#[test] +fn output_style_rejects_a_state_it_did_not_return() { + let result = call_fixture_command( + "outputStyle.decode", + serde_json::json!({ "text": "x", "state": { "line": { "text": "a" }, "pending": "not an escape" } }), + ); + assert_eq!(result["ok"], false); + assert_eq!(result["error"]["code"], "invalid_request"); +} diff --git a/rust/crates/terminal/Cargo.toml b/rust/crates/terminal/Cargo.toml index 2d0d043e..b8528e02 100644 --- a/rust/crates/terminal/Cargo.toml +++ b/rust/crates/terminal/Cargo.toml @@ -3,8 +3,11 @@ name = "lithe-terminal" version = "0.1.0" edition = "2021" license = "Apache-2.0" -description = "Terminal output link detection shared by every Lithe host" +description = "Terminal output link detection and ANSI style decoding shared by every Lithe host" [dependencies] lithe-protocol = { path = "../protocol" } serde = { version = "1.0", features = ["derive"] } + +[dev-dependencies] +serde_json = { version = "1.0", features = ["preserve_order"] } diff --git a/rust/crates/terminal/src/lib.rs b/rust/crates/terminal/src/lib.rs index 3013bb1f..e020f83f 100644 --- a/rust/crates/terminal/src/lib.rs +++ b/rust/crates/terminal/src/lib.rs @@ -1,5 +1,10 @@ //! Terminal output interpretation shared by the native hosts. mod link; +mod style; pub use link::{parse_link_targets, ParseLinkTargetsRequest}; +pub use style::{ + decode_output_style, DecodeOutputStyleRequest, DecodeOutputStyleResponse, OutputColor, + OutputLine, OutputSpan, OutputStyle, OutputStyleState, MAX_LINE_BYTES, MAX_SPANS_PER_LINE, +}; diff --git a/rust/crates/terminal/src/style.rs b/rust/crates/terminal/src/style.rs new file mode 100644 index 00000000..7a6189db --- /dev/null +++ b/rust/crates/terminal/src/style.rs @@ -0,0 +1,757 @@ +//! Decodes console output that carries ANSI escape sequences into plain lines with styled ranges. +//! +//! Build tools and run processes colour their output with SGR sequences when asked to (for +//! example Maven with `-Dstyle.color=always`). Hosts keep and show plain text, so copying, +//! searching, output links and the IDE API never see an escape, and draw the styled ranges with +//! their theme. Core owns what the characters mean: which sequences style text, which are dropped, +//! how a carriage return rewrites a line and how long a line may grow. The host owns the palette +//! and how a style is drawn. +//! +//! Decoding keeps no state between calls. Every answer returns the state the next chunk of the +//! same console continues from: an escape sequence the chunk ended inside, the style in effect, +//! a pending carriage return and the line the output has not ended yet. Splitting the same output +//! into different chunks therefore never changes the lines. + +use lithe_protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; + +/// Largest chunk one request may carry, in UTF-8 bytes. +const MAX_TEXT_BYTES: usize = 1 << 20; +/// Longest line Core keeps, in UTF-8 bytes; longer output continues on the next line. +pub const MAX_LINE_BYTES: usize = 16 << 10; +/// Most styled ranges one line keeps; text styled after the last of them is plain. +pub const MAX_SPANS_PER_LINE: usize = 512; +/// Longest unfinished escape sequence carried to the next chunk, in UTF-8 bytes. +const MAX_PENDING_BYTES: usize = 4096; + +const ESC: char = '\u{1b}'; + +/// A colour an SGR sequence selected. +/// +/// The host maps palette entries 0–15 to its theme's ANSI colours and 16–255 to the xterm +/// 6×6×6 cube and grey ramp. On the wire it is `{"index": n}` or `{"rgb": "#rrggbb"}`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "ColorWire", into = "ColorWire")] +pub enum OutputColor { + /// A palette entry: 0–7 normal, 8–15 bright, 16–255 the extended palette. + Index(u8), + /// A 24-bit colour: red, green and blue. + Rgb([u8; 3]), +} + +/// Wire form of [`OutputColor`]; exactly one member is present. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ColorWire { + #[serde(default, skip_serializing_if = "Option::is_none")] + index: Option, + /// `#rrggbb` in lowercase hexadecimal. + #[serde(default, skip_serializing_if = "Option::is_none")] + rgb: Option, +} + +impl From for ColorWire { + fn from(color: OutputColor) -> Self { + match color { + OutputColor::Index(index) => Self { + index: Some(index), + rgb: None, + }, + OutputColor::Rgb([red, green, blue]) => Self { + index: None, + rgb: Some(format!("#{red:02x}{green:02x}{blue:02x}")), + }, + } + } +} + +impl TryFrom for OutputColor { + type Error = String; + + fn try_from(wire: ColorWire) -> Result { + match (wire.index, wire.rgb) { + (Some(index), None) => Ok(Self::Index(index)), + (None, Some(rgb)) => { + let digits = rgb + .strip_prefix('#') + .filter(|digits| digits.len() == 6 && digits.is_ascii()) + .ok_or_else(|| format!("invalid colour {rgb:?}"))?; + let channel = |at: usize| { + u8::from_str_radix(&digits[at..at + 2], 16) + .map_err(|_| format!("invalid colour {rgb:?}")) + }; + Ok(Self::Rgb([channel(0)?, channel(2)?, channel(4)?])) + } + _ => Err("a colour has exactly one of index and rgb".to_owned()), + } + } +} + +/// The rendition SGR set for a range of text. Absent members are the console's defaults. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OutputStyle { + /// Text colour; absent is the console's text colour. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub foreground: Option, + /// Background colour; absent is the console's background. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background: Option, + /// Bold (SGR 1). + #[serde(default, skip_serializing_if = "is_false")] + pub bold: bool, + /// Faint (SGR 2): the host draws the text dimmer. + #[serde(default, skip_serializing_if = "is_false")] + pub dim: bool, + /// Italic (SGR 3). + #[serde(default, skip_serializing_if = "is_false")] + pub italic: bool, + /// Single or double underline (SGR 4 and 21); the host draws one line. + #[serde(default, skip_serializing_if = "is_false")] + pub underline: bool, + /// Reverse video (SGR 7): the host swaps the text and background colours. + #[serde(default, skip_serializing_if = "is_false")] + pub inverse: bool, + /// Concealed (SGR 8): the host draws the text in its background colour. + #[serde(default, skip_serializing_if = "is_false")] + pub hidden: bool, + /// Crossed out (SGR 9). + #[serde(default, skip_serializing_if = "is_false")] + pub strikethrough: bool, +} + +impl OutputStyle { + fn is_plain(&self) -> bool { + *self == Self::default() + } +} + +fn is_false(value: &bool) -> bool { + !*value +} + +/// One styled range of a line. `start` and `length` count UTF-16 code units, as output links do. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OutputSpan { + /// First UTF-16 code unit of the range. + pub start: u32, + /// UTF-16 code units in the range; never zero. + pub length: u32, + /// How the range is drawn; never the plain style. + pub style: OutputStyle, +} + +/// One line of decoded output: its plain text without the line ending, and its styled ranges +/// in ascending order without overlaps. Text outside every range is plain; adjacent text with +/// the same style shares one range. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OutputLine { + /// The line's text without escapes or the line ending. + pub text: String, + /// The styled ranges, omitted when the whole line is plain. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub spans: Vec, +} + +/// Where one console's output stands after a chunk. +/// +/// The host passes it back unchanged with the next chunk of the same console and shows `line` +/// after the finished lines; it reads nothing else in it. Dropping the state ends the console: +/// the open line stays as it is and an unfinished escape sequence is discarded. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OutputStyleState { + /// The line the output has not ended yet; empty text when the last chunk ended a line. + #[serde(default)] + pub line: OutputLine, + /// The style the next character is drawn with. + #[serde(default, skip_serializing_if = "OutputStyle::is_plain")] + pub style: OutputStyle, + /// The start of an escape sequence the previous chunk ended inside. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub pending: String, + /// A carriage return moved to the start of `line`: the next printed character replaces it. + #[serde(default, skip_serializing_if = "is_false")] + pub carriage: bool, +} + +/// `outputStyle.decode` request: the next chunk of one console and the state the previous +/// chunk left. A console's first chunk carries no state. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DecodeOutputStyleRequest { + /// The chunk, at most 1 MiB of UTF-8. + pub text: String, + /// The state the previous answer for this console returned, unchanged. + #[serde(default)] + pub state: Option, +} + +/// `outputStyle.decode` answer. +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DecodeOutputStyleResponse { + /// The lines this chunk ended, oldest first; the first continues the request's open line. + pub lines: Vec, + /// The state for the next chunk; its `line` is the line still open. + pub state: OutputStyleState, +} + +/// `outputStyle.decode`: decodes one chunk of console output. +/// +/// SGR sequences (`ESC [ … m`) set the style of the text after them, across chunk and line +/// boundaries. `\n` ends a line, `\r` makes the next printed text replace the line (so +/// `\r\n` is a plain line ending and progress rewrites keep only their last state), erase in +/// line (`ESC [ 2 K`, and `ESC [ K` right after a carriage return) clears it, and moving to the +/// first column (`ESC [ G`) acts as `\r`. Every other control sequence, string sequence (OSC, +/// DCS, APC, PM, SOS) and escape, and every control character except tab, is dropped; the text +/// between OSC 8 hyperlink markers is kept. Lines longer than 16 KiB continue on the next line. +/// +/// # Errors +/// +/// `InvalidRequest` when the chunk is larger than 1 MiB or the state is not one this command +/// returned: an open line over the limits, ranges outside the line or out of order, or a +/// pending sequence that is not an escape. +pub fn decode_output_style( + request: DecodeOutputStyleRequest, +) -> Result { + if request.text.len() > MAX_TEXT_BYTES { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The output chunk is too large.", + )); + } + let state = request.state.unwrap_or_default(); + let units = validated_units(&state)?; + let mut input = state.pending; + input.push_str(&request.text); + let mut decoder = Decoder { + line: LineBuilder { + text: state.line.text, + units, + spans: state.line.spans, + }, + style: state.style, + carriage: state.carriage, + finished: Vec::new(), + }; + let pending = decoder.run(&input); + Ok(DecodeOutputStyleResponse { + lines: decoder.finished, + state: OutputStyleState { + line: decoder.line.take(), + style: decoder.style, + pending, + carriage: decoder.carriage, + }, + }) +} + +/// Checks a state the host passed back and returns the UTF-16 length of its open line. +fn validated_units(state: &OutputStyleState) -> Result { + let invalid = || { + CoreError::new( + ErrorCode::InvalidRequest, + "The output style state is invalid.", + ) + }; + let line = &state.line; + if line.text.len() > MAX_LINE_BYTES + || line.spans.len() > MAX_SPANS_PER_LINE + || state.pending.len() > MAX_PENDING_BYTES + || !(state.pending.is_empty() || state.pending.starts_with(ESC)) + { + return Err(invalid()); + } + let units = line.text.encode_utf16().count() as u32; + let mut end = 0u32; + for span in &line.spans { + if span.length == 0 + || span.start < end + || span.style.is_plain() + || u64::from(span.start) + u64::from(span.length) > u64::from(units) + { + return Err(invalid()); + } + end = span.start + span.length; + } + Ok(units) +} + +/// The open line while a chunk is decoded. Its UTF-16 length is kept so a range never needs +/// a rescan of the text. +struct LineBuilder { + text: String, + units: u32, + spans: Vec, +} + +impl LineBuilder { + fn clear(&mut self) { + self.text.clear(); + self.units = 0; + self.spans.clear(); + } + + fn take(&mut self) -> OutputLine { + self.units = 0; + OutputLine { + text: std::mem::take(&mut self.text), + spans: std::mem::take(&mut self.spans), + } + } + + /// Appends one character drawn with `style`, extending the last range when it ends here + /// with the same style. + fn push(&mut self, ch: char, style: &OutputStyle) { + let width = ch.len_utf16() as u32; + self.text.push(ch); + if !style.is_plain() { + let count = self.spans.len(); + match self.spans.last_mut() { + Some(last) if last.start + last.length == self.units && last.style == *style => { + last.length += width; + } + _ if count < MAX_SPANS_PER_LINE => self.spans.push(OutputSpan { + start: self.units, + length: width, + style: *style, + }), + _ => {} + } + } + self.units += width; + } +} + +/// Decoding state for one call. +struct Decoder { + line: LineBuilder, + style: OutputStyle, + carriage: bool, + finished: Vec, +} + +/// What an escape character at some position starts. +enum Scan<'a> { + /// The input ended inside the sequence. `string` is true for a string sequence (OSC, DCS, + /// APC, PM, SOS), whose contents up to the terminator are dropped. + Incomplete { string: bool }, + /// A complete control sequence (CSI): parameter bytes, intermediate bytes and final byte. + Control { + parameters: &'a str, + intermediates: &'a str, + final_byte: u8, + end: usize, + }, + /// Any other sequence, or one a byte that cannot belong to it ended; it is dropped and + /// decoding resumes at `end`. + Skip { end: usize }, +} + +impl Decoder { + /// Decodes `input` and returns the unfinished escape sequence it ends inside, if any. + fn run(&mut self, input: &str) -> String { + let mut index = 0; + while let Some(ch) = input[index..].chars().next() { + if ch == ESC { + match scan_escape(input, index) { + Scan::Incomplete { string } => return pending(&input[index..], string), + Scan::Control { + parameters, + intermediates, + final_byte, + end, + } => { + self.control(parameters, intermediates, final_byte); + index = end; + } + Scan::Skip { end } => index = end, + } + continue; + } + match ch { + '\n' => { + self.finished.push(self.line.take()); + self.carriage = false; + } + '\r' => self.carriage = true, + '\t' => self.print(ch), + // C0 controls, DEL and C1 controls (BEL, backspace, …) have no drawing. + _ if ch.is_control() => {} + _ => self.print(ch), + } + index += ch.len_utf8(); + } + String::new() + } + + fn print(&mut self, ch: char) { + if self.carriage { + self.line.clear(); + self.carriage = false; + } + if self.line.text.len() + ch.len_utf8() > MAX_LINE_BYTES { + self.finished.push(self.line.take()); + } + self.line.push(ch, &self.style); + } + + fn control(&mut self, parameters: &str, intermediates: &str, final_byte: u8) { + // Private sequences (a leading `<`, `=`, `>` or `?`, such as xterm's `CSI > 4 ; 2 m`) + // and sequences with intermediates are not SGR, EL or CHA whatever their final byte. + if !intermediates.is_empty() || parameters.starts_with(['<', '=', '>', '?']) { + return; + } + match final_byte { + b'm' => apply_sgr(&mut self.style, parameters), + // Erase in line. After a carriage return the cursor is at the first column, so + // erasing to the end clears the whole line. + b'K' => match parameters { + "" | "0" if self.carriage => { + self.line.clear(); + self.carriage = false; + } + "2" => self.line.clear(), + _ => {} + }, + // Cursor to the first column acts as a carriage return. + b'G' if matches!(parameters, "" | "0" | "1") => self.carriage = true, + _ => {} + } + } +} + +/// Reads the escape sequence that starts at `start` (an ESC character). Every byte it looks at +/// past the ESC is ASCII, so each position it returns is a character boundary. +fn scan_escape(input: &str, start: usize) -> Scan<'_> { + let bytes = input.as_bytes(); + let Some(&kind) = bytes.get(start + 1) else { + return Scan::Incomplete { string: false }; + }; + match kind { + b'[' => { + let parameters_start = start + 2; + let mut index = parameters_start; + while bytes + .get(index) + .is_some_and(|byte| (0x30..=0x3f).contains(byte)) + { + index += 1; + } + let parameters_end = index; + while bytes + .get(index) + .is_some_and(|byte| (0x20..=0x2f).contains(byte)) + { + index += 1; + } + match bytes.get(index) { + None => Scan::Incomplete { string: false }, + Some(&final_byte) if (0x40..=0x7e).contains(&final_byte) => Scan::Control { + parameters: &input[parameters_start..parameters_end], + intermediates: &input[parameters_end..index], + final_byte, + end: index + 1, + }, + // A byte that cannot belong to a control sequence ends it unfinished and is + // decoded again as output. + Some(_) => Scan::Skip { end: index }, + } + } + b']' | b'P' | b'X' | b'^' | b'_' => { + let mut index = start + 2; + while let Some(&byte) = bytes.get(index) { + match byte { + // BEL ends OSC in xterm's form; ST is `ESC \`. + 0x07 => return Scan::Skip { end: index + 1 }, + 0x1b => { + return match bytes.get(index + 1) { + Some(b'\\') => Scan::Skip { end: index + 2 }, + // Another escape cancels the string; it is decoded on its own. + Some(_) => Scan::Skip { end: index }, + None => Scan::Incomplete { string: true }, + }; + } + _ => index += 1, + } + } + Scan::Incomplete { string: true } + } + 0x20..=0x2f => { + let mut index = start + 2; + while bytes + .get(index) + .is_some_and(|byte| (0x20..=0x2f).contains(byte)) + { + index += 1; + } + match bytes.get(index) { + None => Scan::Incomplete { string: false }, + Some(0x30..=0x7e) => Scan::Skip { end: index + 1 }, + Some(_) => Scan::Skip { end: index }, + } + } + 0x30..=0x7e => Scan::Skip { end: start + 2 }, + // A lone ESC: the character after it is decoded again as output. + _ => Scan::Skip { end: start + 1 }, + } +} + +/// The unfinished sequence carried to the next chunk. A string sequence over the limit keeps +/// only its introducer (and a trailing ESC that may start its terminator), so the next chunk +/// still drops the rest of it; any other sequence over the limit is dropped. +fn pending(tail: &str, string: bool) -> String { + if tail.len() <= MAX_PENDING_BYTES { + return tail.to_owned(); + } + if !string { + return String::new(); + } + let mut kept = tail[..2].to_owned(); + if tail.ends_with(ESC) { + kept.push(ESC); + } + kept +} + +/// A decimal SGR parameter; an empty one is 0, as ECMA-48 defines. +fn number(text: &str) -> Option { + if text.is_empty() { + return Some(0); + } + if text.len() > 5 || !text.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + text.parse().ok() +} + +fn apply_sgr(style: &mut OutputStyle, parameters: &str) { + let groups: Vec<&str> = parameters.split(';').collect(); + let mut index = 0; + while index < groups.len() { + let mut parts = groups[index].split(':'); + index += 1; + let Some(code) = parts.next().and_then(number) else { + continue; + }; + match code { + 0 => *style = OutputStyle::default(), + 1 => style.bold = true, + 2 => style.dim = true, + 3 => style.italic = true, + // `4:0` is "no underline"; `4:1`–`4:5` are underline styles. + 4 => style.underline = parts.next().and_then(number) != Some(0), + 7 => style.inverse = true, + 8 => style.hidden = true, + 9 => style.strikethrough = true, + 21 => style.underline = true, + 22 => { + style.bold = false; + style.dim = false; + } + 23 => style.italic = false, + 24 => style.underline = false, + 27 => style.inverse = false, + 28 => style.hidden = false, + 29 => style.strikethrough = false, + 30..=37 => style.foreground = Some(OutputColor::Index((code - 30) as u8)), + 39 => style.foreground = None, + 40..=47 => style.background = Some(OutputColor::Index((code - 40) as u8)), + 49 => style.background = None, + 90..=97 => style.foreground = Some(OutputColor::Index((code - 90 + 8) as u8)), + 100..=107 => style.background = Some(OutputColor::Index((code - 100 + 8) as u8)), + 38 | 48 | 58 => { + let subparameters: Vec<&str> = parts.collect(); + let color = if subparameters.is_empty() { + // Semicolon form: the colour's values are the next parameters. + let (color, used) = extended_color(&groups[index..], false); + index += used; + color + } else { + extended_color(&subparameters, true).0 + }; + match (code, color) { + (38, Some(color)) => style.foreground = Some(color), + (48, Some(color)) => style.background = Some(color), + // 58 sets the underline colour, which hosts do not draw; its values are + // still consumed so they are not read as other parameters. + _ => {} + } + } + _ => {} + } + } +} + +/// Reads an extended colour (`5;n` or `2;r;g;b`) from the values after 38, 48 or 58, and +/// returns it with the number of values it used. In the colon form the values are the +/// subparameters, all of which belong to the colour. +fn extended_color(values: &[&str], colon: bool) -> (Option, usize) { + let channel = |value: Option<&&str>| { + value + .and_then(|value| number(value)) + .and_then(|value| u8::try_from(value).ok()) + }; + match values.first().and_then(|mode| number(mode)) { + Some(5) => { + let color = channel(values.get(1)).map(OutputColor::Index); + ( + color, + if colon { + values.len() + } else { + values.len().min(2) + }, + ) + } + Some(2) => { + // ITU T.416 puts a colour space ID before the components in the colon form + // (`38:2::r:g:b`); xterm also accepts `38:2:r:g:b`. + let components = if colon && values.len() >= 5 { + &values[2..] + } else { + &values[1..] + }; + let color = (|| { + Some(OutputColor::Rgb([ + channel(components.first())?, + channel(components.get(1))?, + channel(components.get(2))?, + ])) + })(); + ( + color, + if colon { + values.len() + } else { + values.len().min(4) + }, + ) + } + _ => ( + None, + if colon { + values.len() + } else { + values.len().min(1) + }, + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decode(chunks: &[&str]) -> (Vec, OutputStyleState) { + let mut lines = Vec::new(); + let mut state = None; + for chunk in chunks { + let response = decode_output_style(DecodeOutputStyleRequest { + text: (*chunk).to_owned(), + state: state.take(), + }) + .unwrap(); + lines.extend(response.lines); + state = Some(response.state); + } + (lines, state.unwrap()) + } + + fn style(foreground: u8, bold: bool) -> OutputStyle { + OutputStyle { + foreground: Some(OutputColor::Index(foreground)), + bold, + ..OutputStyle::default() + } + } + + #[test] + fn every_split_of_a_chunk_decodes_the_same_lines() { + // Guards the carried state: an escape, a CRLF or a multi-byte character cut at any + // point must not change the result. + let text = "[\u{1b}[1;34mINFO\u{1b}[m] 构建 \u{1b}[38;2;1;2;3m✓\u{1b}]8;;x\u{1b}\\\r\nnext 10%\rnext 100%\n"; + let (whole, whole_state) = decode(&[text]); + for split in (1..text.len()).filter(|at| text.is_char_boundary(*at)) { + let (lines, state) = decode(&[&text[..split], &text[split..]]); + assert_eq!(lines, whole, "split at {split}"); + assert_eq!(state, whole_state, "split at {split}"); + } + } + + #[test] + fn a_long_line_continues_on_the_next_line_with_its_style() { + let text = format!("\u{1b}[31m{}\n", "a".repeat(MAX_LINE_BYTES + 3)); + let (lines, _) = decode(&[&text]); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].text.len(), MAX_LINE_BYTES); + assert_eq!(lines[1].text, "aaa"); + assert_eq!(lines[1].spans[0].style, style(1, false)); + } + + #[test] + fn a_line_never_keeps_more_ranges_than_the_limit() { + let text = "\u{1b}[31ma\u{1b}[32mb".repeat(MAX_SPANS_PER_LINE); + let (_, state) = decode(&[&text]); + assert_eq!(state.line.spans.len(), MAX_SPANS_PER_LINE); + assert_eq!(state.line.text.len(), MAX_SPANS_PER_LINE * 2); + } + + #[test] + fn an_overlong_string_sequence_is_still_dropped_up_to_its_terminator() { + let start = format!("a\u{1b}]8;;{}", "u".repeat(MAX_PENDING_BYTES)); + let (_, state) = decode(&[&start]); + assert_eq!(state.pending, "\u{1b}]"); + let (lines, _) = decode(&[&start, "rest\u{7}b\n"]); + assert_eq!(lines[0].text, "ab"); + } + + #[test] + fn semicolon_colours_consume_their_values() { + // `38;5;1` must not read its `1` as bold, nor `48;2;…;4` its 4 as underline. + let (lines, _) = decode(&["\u{1b}[38;5;1;48;2;9;8;4mx\n"]); + assert_eq!( + lines[0].spans[0].style, + OutputStyle { + foreground: Some(OutputColor::Index(1)), + background: Some(OutputColor::Rgb([9, 8, 4])), + ..OutputStyle::default() + } + ); + } + + #[test] + fn a_state_with_ranges_outside_its_line_is_rejected() { + let error = decode_output_style(DecodeOutputStyleRequest { + text: String::new(), + state: Some(OutputStyleState { + line: OutputLine { + text: "ab".to_owned(), + spans: vec![OutputSpan { + start: 1, + length: 2, + style: style(1, false), + }], + }, + ..OutputStyleState::default() + }), + }) + .unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + } + + #[test] + fn colours_cross_the_wire_as_index_or_hex() { + let color: OutputColor = serde_json::from_str(r##"{"rgb":"#0a0B0c"}"##).unwrap(); + assert_eq!(color, OutputColor::Rgb([10, 11, 12])); + assert_eq!( + serde_json::to_string(&color).unwrap(), + r##"{"rgb":"#0a0b0c"}"## + ); + assert!(serde_json::from_str::(r##"{"index":1,"rgb":"#000000"}"##).is_err()); + assert!(serde_json::from_str::(r##"{"rgb":"#00000g"}"##).is_err()); + } +} diff --git a/rust/fixtures/terminal/output-style-v1.json b/rust/fixtures/terminal/output-style-v1.json new file mode 100644 index 00000000..2b205361 --- /dev/null +++ b/rust/fixtures/terminal/output-style-v1.json @@ -0,0 +1,426 @@ +{ + "version": 1, + "description": "outputStyle.decode: each case sends its chunks in order, passing back the state each answer returned. lines are all finished lines, oldest first; open is the final state's line.", + "cases": [ + { + "name": "maven info line", + "chunks": [ + "[\u001b[1;34mINFO\u001b[m] Building \u001b[1mlithe\u001b[m\n" + ], + "lines": [ + { + "text": "[INFO] Building lithe", + "spans": [ + { + "start": 1, + "length": 4, + "style": { + "foreground": { + "index": 4 + }, + "bold": true + } + }, + { + "start": 16, + "length": 5, + "style": { + "bold": true + } + } + ] + } + ], + "open": { + "text": "" + } + }, + { + "name": "escape split across chunks", + "chunks": [ + "\u001b[3", + "2mok\u001b", + "[0m done\n" + ], + "lines": [ + { + "text": "ok done", + "spans": [ + { + "start": 0, + "length": 2, + "style": { + "foreground": { + "index": 2 + } + } + } + ] + } + ], + "open": { + "text": "" + } + }, + { + "name": "extended colours", + "chunks": [ + "\u001b[38;5;208ma\u001b[48;2;16;32;48mb\u001b[0;38:2::255:0:0mc\u001b[38:5:21md\u001b[m\n" + ], + "lines": [ + { + "text": "abcd", + "spans": [ + { + "start": 0, + "length": 1, + "style": { + "foreground": { + "index": 208 + } + } + }, + { + "start": 1, + "length": 1, + "style": { + "foreground": { + "index": 208 + }, + "background": { + "rgb": "#102030" + } + } + }, + { + "start": 2, + "length": 1, + "style": { + "foreground": { + "rgb": "#ff0000" + } + } + }, + { + "start": 3, + "length": 1, + "style": { + "foreground": { + "index": 21 + } + } + } + ] + } + ], + "open": { + "text": "" + } + }, + { + "name": "invalid extended colours keep the colour", + "chunks": [ + "\u001b[31;38;5;300mz\u001b[38;2;1;2mq\n" + ], + "lines": [ + { + "text": "zq", + "spans": [ + { + "start": 0, + "length": 2, + "style": { + "foreground": { + "index": 1 + } + } + } + ] + } + ], + "open": { + "text": "" + } + }, + { + "name": "bright colours and resets", + "chunks": [ + "\u001b[91;1mE\u001b[22mr\u001b[39;104mb\u001b[49;3;4mi\u001b[23;24;9ms\u001b[29;7;2mv\u001b[27;22mx\n" + ], + "lines": [ + { + "text": "Erbisvx", + "spans": [ + { + "start": 0, + "length": 1, + "style": { + "foreground": { + "index": 9 + }, + "bold": true + } + }, + { + "start": 1, + "length": 1, + "style": { + "foreground": { + "index": 9 + } + } + }, + { + "start": 2, + "length": 1, + "style": { + "background": { + "index": 12 + } + } + }, + { + "start": 3, + "length": 1, + "style": { + "italic": true, + "underline": true + } + }, + { + "start": 4, + "length": 1, + "style": { + "strikethrough": true + } + }, + { + "start": 5, + "length": 1, + "style": { + "inverse": true, + "dim": true + } + } + ] + } + ], + "open": { + "text": "" + } + }, + { + "name": "style continues across lines", + "chunks": [ + "\u001b[32mone\n", + "two\u001b[0m\nthree" + ], + "lines": [ + { + "text": "one", + "spans": [ + { + "start": 0, + "length": 3, + "style": { + "foreground": { + "index": 2 + } + } + } + ] + }, + { + "text": "two", + "spans": [ + { + "start": 0, + "length": 3, + "style": { + "foreground": { + "index": 2 + } + } + } + ] + } + ], + "open": { + "text": "three" + } + }, + { + "name": "carriage return rewrites the line", + "chunks": [ + "Downloading 10%\r", + "Downloading 100%\r\n", + "done" + ], + "lines": [ + { + "text": "Downloading 100%" + } + ], + "open": { + "text": "done" + } + }, + { + "name": "erase in line and first column", + "chunks": [ + "abc\r\u001b[K\n", + "xy\u001b[2Kready\n", + "50%\u001b[1G100%\n", + "keep\u001b[Kme\n" + ], + "lines": [ + { + "text": "" + }, + { + "text": "ready" + }, + { + "text": "100%" + }, + { + "text": "keepme" + } + ], + "open": { + "text": "" + } + }, + { + "name": "other sequences and controls are dropped", + "chunks": [ + "\u001b]0;title\u0007\u001b]8;;https://x.test\u001b\\link\u001b]8;;\u001b\\ \u001bPq#0\u001b\\\u001b[?25l\u001b(Bo\u001b=k\u001b[?25h\u001b[2J\u0007\b\n" + ], + "lines": [ + { + "text": "link ok" + } + ], + "open": { + "text": "" + } + }, + { + "name": "private and intermediate sequences are not styles", + "chunks": [ + "\u001b[>4;2mplain\u001b[1 mstill\n" + ], + "lines": [ + { + "text": "plainstill" + } + ], + "open": { + "text": "" + } + }, + { + "name": "crlf and tabs", + "chunks": [ + "a\tb\r\nc\r\n" + ], + "lines": [ + { + "text": "a\tb" + }, + { + "text": "c" + } + ], + "open": { + "text": "" + } + }, + { + "name": "ranges count utf16 code units", + "chunks": [ + "\u001b[1m✓\u001b[0m 构建 \u001b[33m😀ok\u001b[m\n" + ], + "lines": [ + { + "text": "✓ 构建 😀ok", + "spans": [ + { + "start": 0, + "length": 1, + "style": { + "bold": true + } + }, + { + "start": 5, + "length": 4, + "style": { + "foreground": { + "index": 3 + } + } + } + ] + } + ], + "open": { + "text": "" + } + }, + { + "name": "string sequence split across chunks", + "chunks": [ + "a\u001b]8;;https://exa", + "mple.test\u001b", + "\\b\n" + ], + "lines": [ + { + "text": "ab" + } + ], + "open": { + "text": "" + } + }, + { + "name": "lone escape and unfinished control sequence", + "chunks": [ + "x\u001b\u001b[31\ny\u001b[m\n" + ], + "lines": [ + { + "text": "x" + }, + { + "text": "y" + } + ], + "open": { + "text": "" + } + }, + { + "name": "open line keeps its style", + "chunks": [ + "\u001b[36mcoord" + ], + "lines": [], + "open": { + "text": "coord", + "spans": [ + { + "start": 0, + "length": 5, + "style": { + "foreground": { + "index": 6 + } + } + } + ] + } + } + ] +}