justerm-core 0.17.0

A pure terminal engine: VT byte stream to grid + scrollback + damage. No I/O, no rendering, theme-agnostic.
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
//! #538 — soft-wrap is a property of the **row**, so writing or clearing a cell must not be able
//! to destroy it.
//!
//! justerm stored it in the row's last cell (`CellFlags::WRAPLINE`, from #7), and `Cell` writes
//! and clears are whole-word operations — so ordinary typing in the last column silently split
//! the logical line, injecting a newline into copy and breaking any search across the wrap.
//!
//! Both references keep it out of a cell's reach for this reason: ghostty holds `wrap` on the
//! `Row`, xterm.js holds `isWrapped` on the `BufferLine` and takes `clearWrap` as an explicit
//! argument on its erase helper (`_eraseInBufferLine`) rather than letting a cell clear decide
//! it.
//!
//! The tests below are written against the three ways a cell at the last column gets touched —
//! a plain write, an erase, and a structural repair — plus the one case where the wrap *should*
//! be cleared.

use justerm_core::{CellFlags, Engine};

/// `"abcdZ"` on a 4-column screen: one logical line soft-wrapped across two rows.
fn wrapped_line() -> Engine {
    let mut t = Engine::new(4, 3);
    t.feed(b"abcdZ");
    assert_eq!(t.accessible_text().trim_end(), "abcdZ", "fixture");
    assert_eq!(
        t.search("cdZ").len(),
        1,
        "fixture: searchable across the wrap"
    );
    t
}

#[test]
fn overwriting_the_last_column_keeps_the_line_joined() {
    // The most common trigger by far: no erase, no wide glyph, no repair — just typing.
    let mut t = wrapped_line();
    t.feed(b"\x1b[1;4HQ");
    assert_eq!(
        t.accessible_text().trim_end(),
        "abcQZ",
        "the row is still soft-wrapped, so copy gets one line"
    );
    assert_eq!(
        t.search("cQZ").len(),
        1,
        "and it is still searchable across the wrap"
    );
}

#[test]
fn erasing_rightward_ends_the_wrap_at_any_column() {
    // `EL 0` destroys this row's tail, so it can no longer be continuing — at ANY column, not
    // only when it happens to start at 0.
    //
    // This test asserted the opposite when it was written, on a misreading of xterm.js: that
    // reference's `clearWrap` flag targets `isWrapped`, which marks the row that *continues* the
    // previous one — the opposite link from justerm's. Real xterm ends `ClearRight` with
    // `LineClrWrapped(ld)` unconditionally (`util.c:1871`, comment: *"with the right part
    // cleared, we can't be wrapping"*), and ghostty calls `cursorResetWrap()` in
    // `eraseLine(.right)`. Both were read directly.
    let mut t = wrapped_line();
    t.feed(b"\x1b[1;3H\x1b[K"); // erase from a MID column, not from 0
    assert_eq!(
        t.accessible_text().trim_end(),
        "ab
Z",
        "the erased row stops continuing into the next"
    );
    assert!(!t.grid().is_row_wrapped(0));
}

#[test]
fn erasing_leftward_leaves_the_wrap_alone() {
    // `EL 1`'s mirror: the tail survives, so what it flowed into still follows it. Both
    // references agree here (xterm's `ClearLeft` has no `LineClrWrapped`).
    let mut t = wrapped_line();
    t.feed(b"\x1b[1;3H\x1b[1K");
    assert!(
        t.grid().is_row_wrapped(0),
        "erasing to the left cannot end a wrap"
    );
    assert_eq!(t.accessible_text().trim_end(), "   dZ"); // EL 1 erases through the cursor column
}

#[test]
fn a_structural_repair_at_the_last_column_keeps_the_line_joined() {
    // Overwriting a wide glyph's lead frees its spacer at the last column (#530's `free_cell`).
    let mut t = Engine::new(6, 3);
    t.feed("abcd\u{D55C}".as_bytes()); // 한 at columns 4-5, filling the row
    t.feed(b"Z"); // wraps → row 0 is soft-wrapped
    assert_eq!(t.accessible_text().trim_end(), "abcd\u{D55C}Z", "fixture");
    t.feed(b"\x1b[1;5HX"); // overwrite 한's lead → frees the spacer at the last column
    assert_eq!(
        t.accessible_text().trim_end(),
        "abcdX Z",
        "the freed spacer is a blank inside the joined line"
    );
}

#[test]
fn erasing_the_whole_line_does_clear_the_wrap() {
    // The one case where clearing it is correct — and the rule xterm.js actually implements:
    // `clearWrap` is true for EL 2 and for EL 0 with the cursor at column 0.
    let mut t = wrapped_line();
    t.feed(b"\x1b[1;1H\x1b[2K"); // EL 2 — erase the entire line
    assert_eq!(
        t.accessible_text().trim_end(),
        "\nZ",
        "row 0 is empty and no longer continues into row 1 — the wrap IS cleared here"
    );
    // Row 1's content is its own logical line now.
    assert_eq!(t.search("Z").len(), 1);
}

#[test]
fn the_wrap_survives_a_cell_write_at_the_last_column_across_reflow() {
    // The end-to-end consequence: a resize re-splits by logical line, so a lost wrap changes the
    // resulting geometry, not just the text.
    let mut t = wrapped_line();
    t.feed(b"\x1b[1;4HQ");
    t.resize(8, 3);
    assert_eq!(
        t.accessible_text().trim_end(),
        "abcQZ",
        "widening rejoins the line into one row"
    );
}

// ---- a blanked row must not inherit a wrap ------------------------------------------------
//
// Moving the flag onto `Row` created a new hazard the cell encoding did not have: the paths
// that blank a row blank its *cells*, while the `Row` itself is rotated/reused — so the flag
// rides along into a row the engine has just declared empty.

#[test]
fn a_row_blanked_by_a_region_scroll_is_not_wrapped() {
    let mut t = Engine::new(4, 3);
    t.feed(b"abcdZ"); // row 0 wraps into row 1
    t.feed(b"\x1b[3;1HQ");
    assert!(t.grid().is_row_wrapped(0), "fixture");
    t.feed(b"\x1b[1S"); // SU 1 — rows rotate up, the bottom is blanked
    assert!(
        (0..3).all(|r| !t.grid().is_row_wrapped(r)),
        "no blanked row claims to continue: {:?}",
        (0..3)
            .map(|r| t.grid().is_row_wrapped(r))
            .collect::<Vec<_>>()
    );
}

#[test]
fn a_row_blanked_by_an_alt_screen_scroll_is_not_wrapped() {
    let mut t = Engine::new(4, 3);
    t.feed(b"\x1b[?1049h");
    t.feed(b"abcdZ");
    t.feed(b"\x1b[3;1H\r\n\r\n"); // scroll the alt screen twice
    t.feed(b"PQ");
    assert_eq!(
        t.accessible_text().trim_end().replace('\n', "|"),
        "||PQ",
        "three separate lines — a blanked row must not merge with the one below it"
    );
}

#[test]
fn re_entering_the_alt_screen_starts_unwrapped() {
    // The alt grid persists across `?1049` cycles, so a stale wrap survives into a screen the
    // application believes it just cleared.
    let mut t = Engine::new(4, 3);
    t.feed(b"\x1b[?1049h");
    t.feed(b"abcdZ"); // wraps
    t.feed(b"\x1b[?1049l\x1b[?1049h"); // leave and re-enter
    t.feed(b"ab\r\ncd"); // two independent HARD lines
    assert_eq!(
        t.accessible_text().trim_end(),
        "ab\ncd",
        "two hard lines stay two lines on a freshly entered alt screen"
    );
}

#[test]
fn ed0_from_mid_row_ends_the_wrap_it_erased_through() {
    // `CSI J` from a mid-row cursor erases this row's tail *and every row below*, so the row
    // cannot continue into anything. justerm flags the row that wraps INTO the next, where
    // xterm.js flags the continuation row — so where xterm clears the erased row's own flag,
    // justerm must clear the flag of the row *above* the erased range. For ED 0 that is the
    // cursor's row, which the partial-erase rule deliberately leaves alone.
    let mut t = Engine::new(4, 3);
    t.feed(b"abcdZ");
    t.feed(b"\x1b[1;3H\x1b[J"); // ED 0 from column 2
    t.feed(b"\x1b[2;1HQR"); // redraw on the next row — the ordinary prompt-redraw shape
    assert_eq!(
        t.accessible_text().trim_end(),
        "ab\nQR",
        "the erased row no longer continues into the redrawn one"
    );
}

// ---- the two guards a mutation pass found unprotected -------------------------------------

#[test]
fn the_wire_still_carries_the_wrap_on_the_last_cell() {
    // This is the claim that let the storage move without a format change, and it was resting on
    // one unguarded line: `frame()` derives the bit back onto a span's last cell. Deleting that
    // line left the whole workspace green.
    let mut t = Engine::new(4, 3);
    t.feed(b"abcdZ"); // row 0 wraps
    let frame = t.frame();
    let span = frame
        .spans
        .iter()
        .find(|s| s.line == 0)
        .expect("row 0 is damaged");
    assert!(
        span.cells
            .last()
            .unwrap()
            .flags()
            .contains(CellFlags::WRAPLINE),
        "a wrapped row still ships WRAPLINE on its last cell"
    );
    // …and it is genuinely *derived*: the live cell does not carry it.
    assert!(
        !t.grid().cell(0, 3).flags().contains(CellFlags::WRAPLINE),
        "the live cell never carries it — that is the point of the move"
    );
    // Right reason: a hard-ended row must not ship it.
    let mut hard = Engine::new(4, 3);
    hard.feed(b"ab\r\nc");
    let hf = hard.frame();
    let hs = hf.spans.iter().find(|s| s.line == 0).expect("row 0");
    assert!(
        !hs.cells
            .last()
            .unwrap()
            .flags()
            .contains(CellFlags::WRAPLINE)
    );
}

#[test]
fn a_recycled_row_buffer_does_not_carry_a_wrap_into_its_next_life() {
    // `Row::clear` resets `wrapped` for the buffer `scroll_up_recycle` hands back. With a
    // scrollback cap the evicted row IS the recycled buffer, so a wrapped row's flag would
    // otherwise reappear on a fresh screen row several lines later.
    let mut t = Engine::with_scrollback(4, 2, 1);
    t.feed(b"abcdZ"); // row 0 wraps; it will be evicted first
    // Check after EVERY line. The recycled buffer surfaces for only a step or two before being
    // overwritten again, so a single look at the end misses the leak entirely — measured.
    for i in 0..8 {
        t.feed(format!("\r\nL{i}").as_bytes());
        assert!(
            (0..2).all(|r| !t.grid().is_row_wrapped(r)),
            "after line {i}, a screen row inherited the evicted row's wrap: {:?}",
            (0..2)
                .map(|r| t.grid().is_row_wrapped(r))
                .collect::<Vec<_>>()
        );
    }
}

#[test]
fn ech_ends_the_wrap() {
    // ECH destroys content from the cursor rightward, so the row stops continuing — at any
    // column and for any count. xterm routes ECH through the same `ClearRight` as `EL 0`
    // (`util.c:1961` → `:1871`); ghostty calls `cursorResetWrap()` in `eraseChars`.
    let mut t = wrapped_line();
    t.feed(b"\x1b[1;2H\x1b[1X"); // erase ONE cell, mid-row
    assert!(
        !t.grid().is_row_wrapped(0),
        "ECH ends the wrap even for a single cell in the middle"
    );
    assert_eq!(t.accessible_text().trim_end(), "a cd\nZ");
}

#[test]
fn dch_ends_the_wrap() {
    // DCH pulls the tail left and blanks the far end — ghostty says it outright, "Our row's
    // soft-wrap is always reset".
    let mut t = wrapped_line();
    t.feed(b"\x1b[1;2H\x1b[1P");
    assert!(!t.grid().is_row_wrapped(0), "DCH ends the wrap");
    assert_eq!(t.accessible_text().trim_end(), "acd\nZ");
}

#[test]
fn ich_leaves_the_wrap_alone() {
    // The negative control: inserting blanks does not destroy the tail's meaning — it pushes it
    // right. All references agree ICH does not touch the flag.
    let mut t = wrapped_line();
    t.feed(b"\x1b[1;2H\x1b[1@");
    assert!(t.grid().is_row_wrapped(0), "ICH must not end the wrap");
}

#[test]
fn the_wrap_artefact_and_the_row_flag_never_disagree() {
    // The wide-wrap artefact (#528) is still a *cell* marker at the last column while soft-wrap
    // is now a *row* field, so the two could drift apart: an erase that destroys the marker but
    // leaves the row wrapped turns the artefact's blank into visible text, and a later reflow
    // bakes it in permanently ("abcd한" reading as "abcd 한").
    //
    // The per-verb erase rule closes it — every verb that clears the last column also ends the
    // wrap — so the pair moves together. Pinned here because the coupling is implicit: nothing
    // in the types ties the cell marker to the row flag, and ghostty enforces the same pairing
    // as a page-integrity invariant rather than leaving it to discipline.
    for (label, seq) in [
        ("ECH at the last column", &b"\x1b[1;5H\x1b[1X"[..]),
        ("EL 0 at the last column", &b"\x1b[1;5H\x1b[K"[..]),
        ("DCH from column 0", &b"\x1b[1;1H\x1b[1P"[..]),
    ] {
        let mut t = Engine::new(5, 3);
        t.feed("abcd\u{D55C}".as_bytes()); // 한 cannot fit in the last column -> vacate + wrap
        assert!(t.grid().is_row_wrapped(0), "{label}: fixture");
        t.feed(seq);
        assert!(
            !t.grid().is_row_wrapped(0),
            "{label}: the verb that cleared the artefact also ended the wrap"
        );
        assert!(
            !t.grid().cell(0, 4).is_leading_spacer(),
            "{label}: and the marker went with it — a marker on a hard-ended row would make text \
             extraction skip a real blank"
        );
        let text = t.accessible_text().trim_end().to_string();
        assert!(
            !text.contains(" \u{D55C}"),
            "{label}: no phantom space before the wrapped glyph, got {text:?}"
        );
        t.resize(9, 3);
        assert!(
            !t.accessible_text().contains(" \u{D55C}"),
            "{label}: and a reflow does not bake one in"
        );
    }
}

// ---- the wrap is stored on the row but transmitted on the last cell ------------------------

#[test]
fn changing_the_wrap_reships_the_column_that_carries_it() {
    // The flag lives on `Row` and rides the wire on the last cell. Every other cell-carried fact
    // changes only when that cell is written, so damage covers it for free; `end_wrap` broke that
    // coupling — it can flip the wire bit without touching the column the bit is encoded on, and
    // a `Partial` frame then never re-ships it. The consumer keeps two rows joined forever.
    //
    // This is the branch's central claim ("the format is unchanged, so the storage could move"),
    // so it is asserted rather than assumed.
    let mut t = Engine::new(20, 3);
    t.feed(b"abcdefghijklmnopqrstZ"); // row 0 wraps
    let f0 = t.frame();
    let s0 = f0.spans.iter().find(|s| s.line == 0).expect("row 0");
    assert!(
        s0.cells
            .last()
            .unwrap()
            .flags()
            .contains(CellFlags::WRAPLINE),
        "fixture: the consumer has been told row 0 wraps"
    );

    t.reset_damage();
    t.feed(b"\x1b[1;3H\x1b[2X"); // ECH 2 at column 2 — ends the wrap, far from the last column
    assert!(!t.grid().is_row_wrapped(0), "fixture: the wrap ended");

    let f1 = t.frame();
    let span = f1
        .spans
        .iter()
        .find(|s| s.line == 0 && s.right as usize >= 19)
        .expect("the column carrying the wrap bit is re-shipped when the wrap changes");
    assert!(
        !span.cells[19 - span.left as usize]
            .flags()
            .contains(CellFlags::WRAPLINE),
        "and it now says the row does not wrap"
    );
}

#[test]
fn el1_and_ed1_at_the_last_column_leave_no_orphaned_artefact() {
    // `EL 1` / `ED 1` erase leftward, so by the per-verb rule they correctly do NOT end the wrap
    // — but they still clear the last column, which is where the wide-wrap artefact's marker
    // lives. Leaving the marker there would make the extractors keep skipping a column that is
    // now a real blank, and the marker would describe a glyph that is gone.
    //
    // What is asserted here is the *agreement*, not a particular text: the marker survives only
    // on a row that still wraps. ghostty ties the two the same way — `cursorResetWrap` clears
    // the row's wrap AND a spacer head left in the last cell, in one call.
    for (label, seq) in [
        ("EL 1", &b"\x1b[1;5H\x1b[1K"[..]),
        ("ED 1", &b"\x1b[1;5H\x1b[1J"[..]),
    ] {
        let mut t = Engine::new(5, 3);
        t.feed("abcd\u{D55C}".as_bytes()); // 한 cannot fit in the last column -> vacate + wrap
        assert!(
            t.grid().cell(0, 4).is_leading_spacer(),
            "{label}: fixture — the artefact marker is there to begin with"
        );
        t.feed(seq);
        assert!(
            !t.grid().cell(0, 4).is_leading_spacer(),
            "{label}: the erase blanked the column the marker described, so the marker goes"
        );
        // The blank it leaves is now honest: a real column of the (still-wrapped, for EL 1)
        // logical line rather than one every text reader silently drops.
        let text = t.accessible_text().trim_end().to_string();
        assert!(
            text.ends_with('\u{D55C}'),
            "{label}: the wrapped glyph still reads back, got {text:?}"
        );
    }
}

#[test]
fn ed1_covering_the_whole_row_ends_its_wrap() {
    // `ED 1` with the cursor on the last column erases the entire row, so nothing continues from
    // it. xterm.js has a dedicated arm for exactly this, with the reason in its own words —
    // "Deleted entire previous line. This next line can no longer be wrapped."
    // (`InputHandler.ts:1248-1252`; under its continuation polarity that assignment is this
    // engine's `end_wrap(cursor_row)`.) `EL 1` has no such arm in xterm.js and none here.
    let mut t = Engine::new(4, 3);
    t.feed(b"abcdZ");
    t.feed(b"\x1b[1;4H\x1b[1J"); // ED 1, cursor on the last column
    assert!(!t.grid().is_row_wrapped(0));
    assert_eq!(
        t.accessible_text().trim_end(),
        "\nZ",
        "the emptied row does not merge with the one below it"
    );
}