termlens 0.11.0

Headless PTY test harness for CLI/TUI apps — spawn in a real PTY, assert on the rendered screen
Documentation
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
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! The snapshot text format read back into a [`Screen`] (#255): the
//! `Display` rendering of `docs/DESIGN.md` §3, with or without the
//! `styles:` block `with_styles` adds, so a saved screen — an insta `.snap`,
//! a `TERMLENS_ARTIFACT_DIR` file, a block copied out of a CI log — can be
//! diffed and rendered outside the test run that produced it.
//!
//! What the text format does not carry cannot come back: the out-of-band
//! state is default, and an erased cell and a written blank are both a
//! blank. That is the format's contract, not a loss here — `Screen::diff`
//! and the renderings see the same picture either way.

use unicode_width::UnicodeWidthChar;

use super::{Cell, Color, Screen, Style, TermState};
use crate::{Error, Result};

impl Screen {
    /// Parse the snapshot text format back into a `Screen`.
    ///
    /// Accepts exactly what [`Display`](std::fmt::Display) and
    /// [`with_styles`](Self::with_styles) write: the `size:`/`cursor:`
    /// header, the grid one row per line with trailing blanks stripped
    /// (missing trailing rows and columns are blank), and optionally a blank
    /// line, `styles:` and its span lines (or `(none)`). Everything else is
    /// an [`Error::Parse`] naming the line.
    ///
    /// **The header's row count decides where the grid ends**, so a grid may
    /// contain the words `styles:` or `(none)` as ordinary content. The one
    /// ambiguous input is a snapshot whose trailing blank rows were trimmed
    /// by hand *and* which carries a styles block: its block falls inside
    /// the declared row count and is read as content. Content wins, on
    /// purpose — a wrong row of text shows up in a diff, a silently dropped
    /// one does not.
    ///
    /// A hidden cursor's position is not in the text format, so it comes
    /// back at `0,0`. That is not a difference anyone can see, and
    /// [`diff`](Self::diff) does not report it as one.
    ///
    /// The round trip is exact for what the format carries: the parsed
    /// screen renders to the same text, with the same `styles:` block, and
    /// [`diff`](Self::diff)s empty against the original. It does not
    /// compare `==` to the original — the title, the modes, the counters
    /// and the history are not in the text and come back default.
    ///
    /// ```
    /// # fn main() -> termlens::Result<()> {
    /// # let mut t = termlens::Terminal::builder()
    /// #     .args(["-c", "printf '\\033[1;31mError:\\033[0m disk full'; read q"]).spawn("sh")?;
    /// # t.wait_until(|s| s.contains("disk full"))?;
    /// let saved = t.screen().with_styles().to_string();
    /// let parsed = termlens::Screen::parse(&saved)?;
    /// assert_eq!(parsed.with_styles().to_string(), saved);
    /// assert!(t.screen().diff(&parsed).is_empty());
    /// # t.send(termlens::Key::Enter); t.wait_exit()?; Ok(())
    /// # }
    /// ```
    pub fn parse(text: &str) -> Result<Screen> {
        let mut lines = text.lines();
        let header = lines
            .next()
            .ok_or_else(|| Error::Parse("empty input; expected a `size: …` header".into()))?;
        let (cols, rows, cursor) = parse_header(header)?;
        let body: Vec<&str> = lines.collect();

        // The grid is the number of lines the header declares, and nothing
        // else decides that. `Display` writes every row — blank ones
        // included — before `with_styles` adds its block, so counting is
        // unambiguous for text this crate produced. Deliberately *not* a
        // search for the `styles:` marker: a grid can contain that word as
        // ordinary content, and reading content as metadata silently
        // deleted it (#296).
        //
        // Fewer lines than rows is a snapshot whose trailing blank rows were
        // trimmed; the remainder pads out blank. The cost of the rule is at
        // the other end: in a *trimmed* snapshot that also carries a styles
        // block, the block's lines are within the declared row count and are
        // read as grid content. Content wins ties, on purpose — a wrong row
        // of text is visible in a diff, a silently dropped one is not.
        let split = body.len().min(usize::from(rows));
        let (grid, rest) = body.split_at(split);

        let mut cells = vec![
            Cell::new(String::new(), Style::default(), false, false);
            usize::from(cols) * usize::from(rows)
        ];
        for (index, line) in grid.iter().enumerate() {
            parse_row(
                line,
                index + 2,
                cols,
                &mut cells[index * usize::from(cols)..][..usize::from(cols)],
            )?;
        }

        let mut screen = Screen::from_parts(
            cols,
            rows,
            cursor.0,
            cursor.1,
            cursor.2,
            cells,
            TermState::default(),
        );

        // After the grid: blank separator lines, then `styles:` and its
        // spans, or nothing at all.
        if let Some(at) = rest.iter().position(|line| !line.trim().is_empty()) {
            let number = split + at + 2;
            if rest[at].trim_end() != "styles:" {
                return Err(Error::Parse(format!(
                    "line {number}: expected `styles:` or the end of the text after the {rows}-row grid, got {:?}",
                    rest[at]
                )));
            }
            parse_styles(&rest[at + 1..], number + 1, &mut screen)?;
        }
        Ok(screen)
    }
}

/// `size: <cols>x<rows>  cursor: <row>,<col>` or `cursor: hidden`.
fn parse_header(line: &str) -> Result<(u16, u16, (u16, u16, bool))> {
    let bad = || {
        Error::Parse(format!("line 1: expected `size: COLSxROWS  cursor: ROW,COL` (or `cursor: hidden`), got {line:?}"))
    };
    let rest = line.strip_prefix("size: ").ok_or_else(bad)?;
    let (size, cursor) = rest.split_once("cursor:").ok_or_else(bad)?;
    let (cols, rows) = size.trim().split_once('x').ok_or_else(bad)?;
    let cols: u16 = cols.parse().map_err(|_| bad())?;
    let rows: u16 = rows.parse().map_err(|_| bad())?;
    if cols == 0 || rows == 0 {
        return Err(Error::Parse(format!(
            "line 1: a screen has at least one column and one row, got {cols}x{rows}"
        )));
    }
    let cursor = cursor.trim();
    let cursor = if cursor == "hidden" {
        (0, 0, false)
    } else {
        let (r, c) = cursor.split_once(',').ok_or_else(bad)?;
        let r: u16 = r.parse().map_err(|_| bad())?;
        let c: u16 = c.parse().map_err(|_| bad())?;
        if r >= rows || c >= cols {
            return Err(Error::Parse(format!(
                "line 1: cursor {r},{c} is outside the {cols}x{rows} grid"
            )));
        }
        (r, c, true)
    };
    Ok((cols, rows, cursor))
}

/// One grid line into `row`, a slice of exactly `cols` cells. Zero-width
/// characters join the cell before them; a wide character takes two.
fn parse_row(line: &str, number: usize, cols: u16, row: &mut [Cell]) -> Result<()> {
    let mut col = 0usize;
    for ch in line.chars() {
        let width = ch.width().unwrap_or(0);
        if width == 0 {
            // The cell that owns the glyph before this mark. A wide
            // character advanced the column by two, so one step back lands
            // on its continuation half, which holds no text — the mark
            // belongs to the leading cell another step back (#297).
            let owner = col.checked_sub(1).map(|c| {
                if row[c].wide_continuation {
                    c.saturating_sub(1)
                } else {
                    c
                }
            });
            match owner.map(|c| &mut row[c]) {
                Some(cell) if !cell.contents.is_empty() => cell.contents.push(ch),
                _ => {
                    return Err(Error::Parse(format!(
                        "line {number}: {ch:?} has no character to combine with"
                    )));
                }
            }
            continue;
        }
        if col + width > usize::from(cols) {
            return Err(Error::Parse(format!(
                "line {number}: the row is wider than the {cols} columns the header declares"
            )));
        }
        row[col] = Cell::new(ch.to_string(), Style::default(), width == 2, false);
        if width == 2 {
            row[col + 1] = Cell::new(String::new(), Style::default(), false, true);
        }
        col += width;
    }
    Ok(())
}

/// The span lines after `styles:` — `(none)`, or `<row>: <spans>` — applied
/// onto `screen`'s cells. `first_line` is the 1-based number of the first,
/// for the errors.
fn parse_styles(lines: &[&str], first_line: usize, screen: &mut Screen) -> Result<()> {
    if lines.is_empty() {
        return Ok(());
    }
    let cols = screen.cols;
    let mut cells: Vec<Cell> = screen.cells.to_vec();
    for (offset, line) in lines.iter().enumerate() {
        let number = first_line + offset;
        let line = line.trim_end();
        if line.is_empty() || line == "(none)" {
            continue;
        }
        let bad = |what: &str| Error::Parse(format!("line {number}: {what} in {line:?}"));
        let (row, spans) = line
            .split_once(": ")
            .ok_or_else(|| bad("expected `ROW: SPANS`"))?;
        let row: u16 = row.trim().parse().map_err(|_| bad("bad row number"))?;
        if row >= screen.rows {
            return Err(bad("row is outside the grid"));
        }
        for span in spans.split("; ") {
            let mut tokens = span.split_whitespace();
            let range = tokens.next().ok_or_else(|| bad("empty span"))?;
            let column = |text: &str| text.parse::<u16>().map_err(|_| bad("bad column"));
            let (start, end) = match range.split_once('-') {
                Some((s, e)) => (column(s)?, column(e)?),
                None => {
                    let c = column(range)?;
                    (c, c)
                }
            };
            if end < start || end >= cols {
                return Err(bad("span is outside the grid"));
            }
            let mut style = Style::default();
            for token in tokens {
                match token {
                    "bold" => style.bold = true,
                    "dim" => style.dim = true,
                    "italic" => style.italic = true,
                    "underline" => style.underline = true,
                    "blink" => style.blink = true,
                    "reverse" => style.reverse = true,
                    "conceal" => style.conceal = true,
                    "strikethrough" => style.strikethrough = true,
                    _ => {
                        if let Some(color) = token.strip_prefix("fg=") {
                            style.fg = parse_color(color).ok_or_else(|| bad("bad colour"))?;
                        } else if let Some(color) = token.strip_prefix("bg=") {
                            style.bg = parse_color(color).ok_or_else(|| bad("bad colour"))?;
                        } else {
                            return Err(bad("unknown style token"));
                        }
                    }
                }
            }
            for col in start..=end {
                cells[usize::from(row) * usize::from(cols) + usize::from(col)].style = style;
            }
        }
    }
    screen.cells = cells.into();
    Ok(())
}

/// `4` (indexed) or `#rrggbb`.
///
/// Six *bytes* of hex is not six hex digits: `#a\u{20ac}bc` is six bytes and
/// slicing it in pairs lands inside a character, which used to panic where
/// the whole point of this module is to turn bad text into `Error::Parse`
/// (#299). Every byte is checked before any of them is read as a digit.
fn parse_color(text: &str) -> Option<Color> {
    if let Some(hex) = text.strip_prefix('#') {
        let hex = hex.as_bytes();
        if hex.len() != 6 || !hex.iter().all(u8::is_ascii_hexdigit) {
            return None;
        }
        let digit = |i: usize| char::from(hex[i]).to_digit(16).map(|d| d as u8);
        let channel = |i: usize| Some(digit(i)? * 16 + digit(i + 1)?);
        return Some(Color::Rgb(channel(0)?, channel(2)?, channel(4)?));
    }
    text.parse().ok().map(Color::Indexed)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_grid_with_styles_round_trips() {
        let saved = "size: 6x2  cursor: 1,2\nab 東\n\n\nstyles:\n0: 0-1 fg=1 bold; 3-4 bg=#1e1e2e\n1: 5 reverse";
        let screen = Screen::parse(saved).unwrap();
        assert_eq!(screen.size(), (6, 2));
        assert_eq!(screen.cursor(), (1, 2, true));
        assert!(screen.cell(0, 3).unwrap().is_wide());
        assert!(screen.cell(0, 4).unwrap().is_wide_continuation());
        assert_eq!(screen.cell(0, 0).unwrap().style().fg, Color::Indexed(1));
        assert_eq!(
            screen.cell(0, 4).unwrap().style().bg,
            Color::Rgb(0x1e, 0x1e, 0x2e)
        );
        assert!(screen.cell(1, 5).unwrap().style().reverse);
        assert_eq!(screen.with_styles().to_string(), saved);
    }

    #[test]
    fn a_short_grid_is_padded_and_none_is_accepted() {
        // `lines()` drops the empty piece after a trailing newline, so a
        // full Display of a screen ending in blank rows is already "short".
        let screen = Screen::parse("size: 4x3  cursor: hidden\nhi").unwrap();
        assert_eq!(screen.row_text(0), "hi  ");
        assert_eq!(screen.row_text(2), "    ");
        assert_eq!(screen.cursor(), (0, 0, false));
        let padded = Screen::parse("size: 4x3  cursor: hidden\nhi\n\n").unwrap();
        assert!(screen.diff(&padded).is_empty());
        // `(none)` after a full-height grid is the styles block saying the
        // screen carries no styles at all.
        let none = Screen::parse("size: 4x3  cursor: hidden\nhi\n\n\n\nstyles:\n(none)").unwrap();
        assert!(screen.diff(&none).is_empty());
    }

    /// The header's row count decides where the grid ends, so these words
    /// are content when they fall inside it (#296).
    #[test]
    fn a_grid_may_contain_the_words_of_a_styles_block() {
        let saved = "size: 8x3  cursor: 0,0\n\nstyles:\n(none)";
        let screen = Screen::parse(saved).unwrap();
        assert_eq!(screen.row_text(1).trim_end(), "styles:");
        assert_eq!(screen.row_text(2).trim_end(), "(none)");
        assert_eq!(screen.to_string(), saved);
        // Even a row shaped like a style span is text when it is in the grid.
        let spans = "size: 12x2  cursor: 0,0\n0: 0-1 bold\n1: 5 reverse";
        let screen = Screen::parse(spans).unwrap();
        assert_eq!(screen.row_text(0).trim_end(), "0: 0-1 bold");
        assert!(screen.cell(0, 0).unwrap().style().is_default());
        assert_eq!(screen.to_string(), spans);
    }

    /// The one input the row-count rule cannot read both ways: a grid whose
    /// trailing blank rows were trimmed by hand *and* which carries a styles
    /// block. Content wins, and the leftover line is named rather than
    /// silently swallowed.
    #[test]
    fn a_trimmed_grid_with_a_styles_block_is_read_as_content() {
        // Wide enough to hold the word: `styles:` becomes row 2, and the
        // span line after it has nowhere left to belong.
        let err = Screen::parse("size: 12x3  cursor: hidden\nhi\n\nstyles:\n(none)")
            .unwrap_err()
            .to_string();
        assert!(
            err.contains("line 5") && err.contains("expected `styles:`"),
            "{err}"
        );
        // Too narrow to hold it, and the same reading fails one line
        // earlier — on the row itself rather than on what follows it.
        let narrow = Screen::parse("size: 4x3  cursor: hidden\nhi\n\nstyles:\n(none)")
            .unwrap_err()
            .to_string();
        assert!(
            narrow.contains("line 4") && narrow.contains("wider"),
            "{narrow}"
        );
    }

    /// Six bytes of hex is not six hex digits (#299).
    #[test]
    fn a_malformed_colour_is_an_error_not_a_panic() {
        for token in [
            "fg=#a\u{20ac}bc",
            "bg=#a\u{20ac}bc",
            "fg=#12345",
            "fg=#zzzzzz",
            "fg=300",
        ] {
            let input = format!("size: 2x2  cursor: 0,0\nx\n\nstyles:\n0: 0 {token}");
            let err = Screen::parse(&input).unwrap_err().to_string();
            assert!(err.contains("line 5"), "{token}: {err}");
        }
        // The valid forms still parse.
        let ok =
            Screen::parse("size: 2x2  cursor: 0,0\nxy\n\n\nstyles:\n0: 0 fg=4 bg=#1e1e2e").unwrap();
        assert_eq!(ok.cell(0, 0).unwrap().style().fg, Color::Indexed(4));
        assert_eq!(
            ok.cell(0, 0).unwrap().style().bg,
            Color::Rgb(0x1e, 0x1e, 0x2e)
        );
    }

    /// A combining mark after a wide character belongs to the cell that owns
    /// the glyph, not to its continuation half (#297).
    #[test]
    fn a_combining_mark_follows_a_wide_character() {
        let screen = Screen::parse("size: 6x1  cursor: 0,0\n\u{6771}\u{301}X").unwrap();
        assert_eq!(screen.cell(0, 0).unwrap().contents(), "\u{6771}\u{301}");
        assert!(screen.cell(0, 0).unwrap().is_wide());
        assert!(screen.cell(0, 1).unwrap().is_wide_continuation());
        assert_eq!(screen.cell(0, 2).unwrap().contents(), "X");
        // Several marks in a row, and a wide character at the right margin.
        let many = Screen::parse("size: 4x1  cursor: 0,0\nab\u{6771}\u{301}\u{302}").unwrap();
        assert_eq!(
            many.cell(0, 2).unwrap().contents(),
            "\u{6771}\u{301}\u{302}"
        );
        assert!(many.cell(0, 3).unwrap().is_wide_continuation());
    }

    /// `Display for Color` is the forward half of the token `parse_color`
    /// reads (#315): the two halves of one documented format agree.
    #[test]
    fn a_colour_displays_as_the_token_the_parser_reads() {
        for color in [
            Color::Indexed(0),
            Color::Indexed(4),
            Color::Indexed(208),
            Color::Indexed(255),
            Color::Rgb(0x1e, 0x1e, 0x2e),
            Color::Rgb(0, 0, 0),
            Color::Rgb(255, 0, 7),
        ] {
            let token = color.to_string();
            assert_eq!(parse_color(&token), Some(color), "{token}");
        }
        assert_eq!(Color::Indexed(4).to_string(), "4");
        assert_eq!(Color::Rgb(0x1e, 0x1e, 0x2e).to_string(), "#1e1e2e");
        // The block never writes a default colour — absence means default —
        // so the parser does not read the word back; it is for messages.
        assert_eq!(Color::Default.to_string(), "default");
        assert_eq!(parse_color("default"), None);
    }

    #[test]
    fn combining_marks_join_the_cell_before_them() {
        let screen = Screen::parse("size: 3x1  cursor: 0,0\ne\u{301}x").unwrap();
        assert_eq!(screen.cell(0, 0).unwrap().contents(), "e\u{301}");
        assert_eq!(screen.cell(0, 1).unwrap().contents(), "x");
    }

    #[test]
    fn errors_name_the_line() {
        let long = Screen::parse("size: 2x1  cursor: 0,0\nabc")
            .unwrap_err()
            .to_string();
        assert!(long.contains("line 2") && long.contains("wider"), "{long}");
        let header = Screen::parse("80x24").unwrap_err().to_string();
        assert!(header.contains("line 1"), "{header}");
        let cursor = Screen::parse("size: 2x1  cursor: 5,0")
            .unwrap_err()
            .to_string();
        assert!(cursor.contains("outside"), "{cursor}");
        let token = Screen::parse("size: 2x1  cursor: 0,0\nab\n\nstyles:\n0: 0 shiny")
            .unwrap_err()
            .to_string();
        assert!(
            token.contains("line 5") && token.contains("unknown style token"),
            "{token}"
        );
        let junk = Screen::parse("size: 2x1  cursor: 0,0\nab\nextra\n")
            .unwrap_err()
            .to_string();
        assert!(junk.contains("expected `styles:`"), "{junk}");
    }
}