1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
//! Decoding ANSI escape sequences back into styled [`Text`].
//!
//! Port of upstream `rich/ansi.py`. [`AnsiDecoder`] tokenizes a terminal string
//! into plain runs and SGR (Select Graphic Rendition) codes, accumulating a
//! [`Style`] as it goes and emitting one [`Text`] per line. This is the inverse
//! of the [`Console`](crate::console::Console)'s styled output.
//!
//! Scope: SGR styling (attributes, 16/256/truecolor foreground + background) is
//! fully handled, as are **OSC 8 hyperlinks** (`\x1b]8;<params>;<url>\x1b\`): the
//! URL is attached to the running [`Style`] (and cleared by the empty closing
//! sequence). Re-rendering reproduces upstream byte-for-byte except the random
//! `id=` field upstream adds, which we omit for determinism (docs/DIVERGENCES.md
//! #20).
use fancy_regex::Regex;
use std::sync::OnceLock;
use crate::color::Color;
use crate::style::Style;
use crate::text::Text;
/// The SGR parameter → `Style` spec map. Port of `rich.ansi.SGR_STYLE_MAP`.
fn sgr_style(code: u16) -> Option<&'static str> {
let spec = match code {
1 => "bold",
2 => "dim",
3 => "italic",
4 => "underline",
5 => "blink",
6 => "blink2",
7 => "reverse",
8 => "conceal",
9 => "strike",
21 => "underline2",
22 => "not dim not bold",
23 => "not italic",
24 => "not underline",
25 => "not blink",
26 => "not blink2",
27 => "not reverse",
28 => "not conceal",
29 => "not strike",
30 => "color(0)",
31 => "color(1)",
32 => "color(2)",
33 => "color(3)",
34 => "color(4)",
35 => "color(5)",
36 => "color(6)",
37 => "color(7)",
39 => "default",
40 => "on color(0)",
41 => "on color(1)",
42 => "on color(2)",
43 => "on color(3)",
44 => "on color(4)",
45 => "on color(5)",
46 => "on color(6)",
47 => "on color(7)",
49 => "on default",
51 => "frame",
52 => "encircle",
53 => "overline",
54 => "not frame not encircle",
55 => "not overline",
90 => "color(8)",
91 => "color(9)",
92 => "color(10)",
93 => "color(11)",
94 => "color(12)",
95 => "color(13)",
96 => "color(14)",
97 => "color(15)",
100 => "on color(8)",
101 => "on color(9)",
102 => "on color(10)",
103 => "on color(11)",
104 => "on color(12)",
105 => "on color(13)",
106 => "on color(14)",
107 => "on color(15)",
_ => return None,
};
Some(spec)
}
/// The tokenizer regex (port of `rich.ansi.re_ansi`): an OSC string
/// (`\x1b]…\x1b\`) or an escape sequence (single-char or CSI).
fn re_ansi() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?:\x1b\](.*?)\x1b\\)|(?:\x1b([(@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))")
.expect("valid ansi regex")
})
}
/// One token from [`tokenize`]: plain text, an SGR parameter string, or the
/// body of an OSC string (`\x1b]<body>\x1b\`).
enum Token {
Plain(String),
/// The parameters of an `\x1b[…m` sequence (without the `[` and `m`).
Sgr(String),
/// The body of an OSC string (e.g. `8;;https://example.com`).
Osc(String),
}
/// Tokenize a line into plain runs, SGR parameter strings, and OSC bodies,
/// mirroring `_ansi_tokenize`. Non-SGR CSI sequences are dropped.
fn tokenize(line: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let mut position = 0;
for caps in re_ansi().captures_iter(line).flatten() {
let whole = caps.get(0).expect("group 0 always present");
let (start, end) = (whole.start(), whole.end());
if start > position {
tokens.push(Token::Plain(line[position..start].to_string()));
}
match caps.get(2) {
Some(sgr) => {
let sgr = sgr.as_str();
if sgr == "(" {
// Charset-select escape consumes the following byte too.
position = (end + 1).min(line.len());
continue;
}
if let Some(params) = sgr.strip_prefix('[').and_then(|s| s.strip_suffix('m')) {
tokens.push(Token::Sgr(params.to_string()));
}
// Other CSI sequences (e.g. `[2J`) are dropped.
}
None => {
// An OSC string — group 1 is its body (between `\x1b]` and the
// terminating `\x1b\`).
if let Some(osc) = caps.get(1) {
tokens.push(Token::Osc(osc.as_str().to_string()));
}
}
}
position = end;
}
if position < line.len() {
tokens.push(Token::Plain(line[position..].to_string()));
}
tokens
}
/// Translates ANSI codes into styled [`Text`]. Mirrors `rich.ansi.AnsiDecoder`.
///
/// The decoder is stateful: a style set on one line persists to the next, just
/// like a real terminal (and like upstream).
#[derive(Default)]
pub struct AnsiDecoder {
style: Style,
}
impl AnsiDecoder {
pub fn new() -> Self {
AnsiDecoder {
style: Style::new(),
}
}
/// Decode a multi-line terminal string into one [`Text`] per line.
pub fn decode(&mut self, terminal_text: &str) -> Vec<Text> {
// `str::lines` matches Python's `splitlines` for `\n`/`\r\n` endings.
terminal_text.lines().map(|l| self.decode_line(l)).collect()
}
/// Decode a single line containing ANSI codes.
pub fn decode_line(&mut self, line: &str) -> Text {
// A carriage return resets the line: keep only what follows the last one.
let line = line.rsplit('\r').next().unwrap_or(line);
let mut text = Text::new("");
for token in tokenize(line) {
match token {
Token::Plain(plain) => {
let style = if self.style.is_null() {
None
} else {
Some(self.style.clone().into())
};
text.append(&plain, style);
}
Token::Sgr(params) => self.apply_sgr(¶ms),
Token::Osc(osc) => self.apply_osc(&osc),
}
}
text
}
/// Apply an OSC body. Only hyperlinks (`8;<params>;<url>`) are meaningful:
/// the params (e.g. `id=…`) are ignored, and the URL is attached to — or,
/// when empty, cleared from — the running style. Port of the OSC branch of
/// `decode_line`.
fn apply_osc(&mut self, osc: &str) {
if let Some(rest) = osc.strip_prefix("8;") {
// partition on the first ';': everything after it is the link.
if let Some(idx) = rest.find(';') {
let link = &rest[idx + 1..];
let link = (!link.is_empty()).then(|| link.to_string());
self.style = self.style.update_link(link);
}
}
}
/// Apply an SGR parameter string (e.g. `"1;31"`) to the running style.
fn apply_sgr(&mut self, params: &str) {
// Lenient parse: keep digit runs (clamped to 255) and empty fields (0).
let codes: Vec<u16> = params
.split(';')
.filter(|c| c.is_empty() || c.bytes().all(|b| b.is_ascii_digit()))
.map(|c| c.parse::<u32>().unwrap_or(0).min(255) as u16)
.collect();
let mut iter = codes.into_iter();
while let Some(code) = iter.next() {
if code == 0 {
self.style = Style::new();
} else if let Some(spec) = sgr_style(code) {
if let Ok(parsed) = Style::parse(spec) {
self.style = self.style.combine(&parsed);
}
} else if code == 38 {
if let Some(color) = read_extended_color(&mut iter) {
self.style = self.style.combine(&Style::from_color(Some(color), None));
}
} else if code == 48 {
if let Some(color) = read_extended_color(&mut iter) {
self.style = self.style.combine(&Style::from_color(None, Some(color)));
}
}
}
}
}
/// Read the color following a `38`/`48` code: `5;<n>` (8-bit) or `2;<r>;<g>;<b>`
/// (truecolor). Returns `None` if the sequence is truncated (lenient, like
/// upstream's `suppress(StopIteration)`).
fn read_extended_color(iter: &mut impl Iterator<Item = u16>) -> Option<Color> {
match iter.next()? {
5 => Some(Color::from_ansi(iter.next()? as u8)),
2 => {
let r = iter.next()? as u8;
let g = iter.next()? as u8;
let b = iter.next()? as u8;
Some(Color::from_rgb(r, g, b))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::ColorSystem;
use crate::console::Console;
fn round_trip(input: &str) -> String {
let mut decoder = AnsiDecoder::new();
let console = Console::builder()
.force_terminal(true)
.color_system(Some(ColorSystem::Truecolor))
.width(80)
.build();
decoder
.decode(input)
.iter()
.map(|t| console.render_to_string(t))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn plain_text_has_no_style() {
assert_eq!(round_trip("hello"), "hello");
}
#[test]
fn bold_red_round_trips() {
// Both the input and the re-render use rich's own SGR ordering.
assert_eq!(round_trip("\x1b[1;31mhi\x1b[0m"), "\x1b[1;31mhi\x1b[0m");
}
#[test]
fn eight_bit_and_truecolor() {
assert_eq!(
round_trip("\x1b[38;5;214mx\x1b[0m"),
"\x1b[38;5;214mx\x1b[0m"
);
assert_eq!(
round_trip("\x1b[38;2;255;136;0mx\x1b[0m"),
"\x1b[38;2;255;136;0mx\x1b[0m"
);
}
#[test]
fn style_persists_until_reset() {
// "a" is bold; without a reset, "b" on the next segment stays bold.
assert_eq!(round_trip("\x1b[1mab"), "\x1b[1mab\x1b[0m");
}
#[test]
fn non_sgr_csi_is_dropped() {
assert_eq!(round_trip("\x1b[2Jhi"), "hi");
}
#[test]
fn osc8_hyperlink_round_trips() {
// A styled hyperlink: the URL attaches to the running style, so the
// re-render wraps the styled text in OSC 8. Matches real rich 15.0.0
// except upstream's random `id=` field, which we omit (DIVERGENCES #20).
assert_eq!(
round_trip("\x1b]8;;https://example.com\x1b\\\x1b[4;34mlink\x1b[0m\x1b]8;;\x1b\\"),
"\x1b]8;;https://example.com\x1b\\\x1b[4;34mlink\x1b[0m\x1b]8;;\x1b\\"
);
}
#[test]
fn osc8_link_without_style_and_clear() {
// Unstyled link text between plain runs; the empty closing OSC clears the
// link so " after" is plain.
assert_eq!(
round_trip("before \x1b]8;;https://x.io\x1b\\here\x1b]8;;\x1b\\ after"),
"before \x1b]8;;https://x.io\x1b\\here\x1b]8;;\x1b\\ after"
);
}
#[test]
fn osc8_id_param_is_ignored() {
// Upstream includes a random `id=`; when decoding we drop the params and
// keep only the URL, re-emitting without an id.
assert_eq!(
round_trip("\x1b]8;id=42;https://x.io\x1b\\a\x1b]8;;\x1b\\"),
"\x1b]8;;https://x.io\x1b\\a\x1b]8;;\x1b\\"
);
}
}