justerm-core 0.18.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
//! Reverse-wraparound tests (#80, #873, DEC private mode ?45). The mode applies to
//! BACKSPACE and to CURSOR-LEFT alike — one step serves both verbs, which is how xterm
//! and ghostty are built and what #873 decided against xterm.js's deliberate split. It
//! only undoes a SOFT wrap (the row was an autowrap continuation); a hard CR/LF newline
//! does not reverse-wrap, in this engine and in xterm (`LineTstWrapped`, `cursor.c:178`).

use justerm_core::Engine;

#[test]
fn reverse_wrap_backspaces_to_the_previous_soft_wrapped_row() {
    let mut t = Engine::new(3, 2);
    t.feed(b"\x1b[?45h"); // reverse-wrap on
    t.feed(b"abcd"); // "abc" soft-wraps (WRAPLINE on row 0); 'd' at (1,0); cursor (1,1)
    t.feed(b"\x08"); // BS: (1,1) -> (1,0)
    t.feed(b"\x08"); // BS at col 0: reverse-wrap to (0,2)
    t.feed(b"X"); // overwrites the previous row's last cell
    assert_eq!(t.grid().cell(0, 2).c(), 'X');
}

#[test]
fn backspace_clamps_at_column_zero_by_default() {
    let mut t = Engine::new(3, 2);
    t.feed(b"abcd"); // soft wrap, but ?45 is off
    t.feed(b"\x08\x08"); // (1,1) -> (1,0) -> clamp
    t.feed(b"X");
    assert_eq!(
        t.grid().cell(1, 0).c(),
        'X',
        "default: BS clamps at column 0"
    );
}

#[test]
fn reverse_wrap_does_not_cross_a_hard_newline() {
    // Only soft wraps reverse — a hard CR/LF row is not WRAPLINE, so BS clamps.
    let mut t = Engine::new(5, 2);
    t.feed(b"\x1b[?45h");
    t.feed(b"ab\r\nc"); // row 0 "ab" via hard CR/LF (not wrapped); cursor (1,1)
    t.feed(b"\x08\x08"); // (1,1) -> (1,0) -> clamp (prev row not WRAPLINE)
    t.feed(b"X");
    assert_eq!(t.grid().cell(1, 0).c(), 'X');
}

/// `CSI D` at column 0 walks back to the previous soft-wrapped row, exactly as BS does
/// (#873). One step serves both verbs, which is xterm's shape: `CursorBack` is reached
/// from `CASE_BS` (`charproc.c:3703`) and `CASE_CUB` (`:3933`) alike.
#[test]
fn cursor_left_reverse_wraps_to_the_previous_soft_wrapped_row() {
    let mut t = Engine::new(3, 2);
    t.feed(b"\x1b[?45h");
    t.feed(b"abcd"); // soft wrap; cursor (1,1)
    t.feed(b"\x1b[2;1H"); // cursor to (1,0) — the CUP also clears the park
    t.feed(b"\x1b[D"); // cursor-left at column 0 — reverse-wraps
    t.feed(b"Y");
    assert_eq!(
        t.grid().cell(0, 2).c(),
        'Y',
        "CUB walked onto the previous row"
    );
    assert_eq!(t.grid().cell(1, 0).c(), 'd', "and did not clamp in place");
}

/// The control for the walk: with `?45` off, `CSI D` at column 0 clamps.
#[test]
fn cursor_left_clamps_at_column_zero_by_default() {
    let mut t = Engine::new(3, 2);
    t.feed(b"abcd"); // soft wrap, but ?45 is off
    t.feed(b"\x1b[2;1H");
    t.feed(b"\x1b[D");
    t.feed(b"Y");
    assert_eq!(
        t.grid().cell(1, 0).c(),
        'Y',
        "default: CUB clamps at column 0"
    );
}

/// The walk is soft-wraps-only for `CSI D` as well, and this pins it **independently of
/// the shared step**: `reverse_wrap_does_not_cross_a_hard_newline` is the only other test
/// that observes the rule, and it drives `BS`. Found by mutating the walk's predicate to
/// `true` and watching exactly one test redden (#873).
#[test]
fn cursor_left_does_not_cross_a_hard_newline() {
    let mut t = Engine::new(5, 2);
    t.feed(b"\x1b[?45h");
    t.feed(b"ab\x1b[2;1H"); // row 0 "ab" is not WRAPLINE; cursor to (1,0)
    t.feed(b"\x1b[D");
    t.feed(b"X");
    assert_eq!(
        t.grid().cell(1, 0).c(),
        'X',
        "CUB clamped: the previous row is not a soft wrap"
    );
}

/// The walk costs **one unit of the count**, not the whole sequence: xterm decrements
/// once per step inside the loop (`cursor.c:186`), so `CSI 2 D` at column 0 lands one
/// column short of the previous row's end.
#[test]
fn cursor_left_keeps_moving_after_it_walks() {
    let mut t = Engine::new(3, 2);
    t.feed(b"\x1b[?45h");
    t.feed(b"abcd");
    t.feed(b"\x1b[2;1H"); // (1,0)
    t.feed(b"\x1b[2D");
    assert_eq!((t.cursor().row, t.cursor().col), (0, 1));
}

#[test]
fn reverse_wrap_at_home_has_no_effect() {
    let mut t = Engine::new(3, 2);
    t.feed(b"\x1b[?45h");
    t.feed(b"\x1b[1;1H"); // home (0,0)
    t.feed(b"\x08"); // BS at home — no previous line, clamp
    t.feed(b"Z");
    assert_eq!(t.grid().cell(0, 0).c(), 'Z');
}

#[test]
fn decrqm_and_ris_for_reverse_wrap() {
    let mut t = Engine::new(10, 2);
    t.feed(b"\x1b[?45$p"); // off
    assert_eq!(t.drain_replies(), b"\x1b[?45;2$y");
    t.feed(b"\x1b[?45h\x1b[?45$p"); // on
    assert_eq!(t.drain_replies(), b"\x1b[?45;1$y");
    t.feed(b"\x1bc\x1b[?45$p"); // RIS resets, then query
    assert_eq!(t.drain_replies(), b"\x1b[?45;2$y");
}

/// A parked cursor spends the deferred wrap as the **first unit** of a backspace under
/// `?45h`, so the cursor does not move (#80).
///
/// The park means the cursor is logically one past the column it sits on, so the first
/// step back lands *on* that column — which is where it already is. Before this, the
/// flag was cleared and the column decremented anyway, so the logical `+1` was discarded
/// rather than spent and the parked and unparked states collapsed to the same landing.
///
/// Both engines that implement the reverse-wrap decrement do it this way, and gate it on
/// exactly this mode rather than on autowrap: xterm `cursor.c:154-157` —
/// `if ((rev || rev2) && screen->do_wrap) { --count; } else { --col; }` — and ghostty
/// `Terminal.zig:1773-1778`, under a comment saying it is *"to match xterm"*. xterm.js
/// reaches the same landing by letting `x == cols` stand in this branch.
///
/// The control is the whole test: an unparked cursor at the same coordinate must still
/// move, or the fix has simply disabled the backspace.
#[test]
fn reverse_wrap_backspace_spends_a_deferred_wrap_instead_of_moving() {
    // Parked: "abc" soft-wraps, "def" fills row 1, so the cursor sits at (1, 2) with the
    // wrap armed — logically at column 3.
    let mut parked = Engine::new(3, 2);
    parked.feed(b"\x1b[?45h");
    parked.feed(b"abcdef");
    assert!(parked.cursor().pending_wrap, "precondition: parked");
    parked.feed(b"\x08");
    assert_eq!(
        (parked.cursor().row, parked.cursor().col),
        (1, 2),
        "the backspace spent the park and did not move"
    );
    assert!(
        !parked.cursor().pending_wrap,
        "and the park is spent, not still owed"
    );

    // Control: same coordinate, no park. It must move.
    let mut control = Engine::new(3, 2);
    control.feed(b"\x1b[?45h");
    control.feed(b"abcde");
    assert!(!control.cursor().pending_wrap, "precondition: not parked");
    control.feed(b"\x08");
    assert_eq!(
        (control.cursor().row, control.cursor().col),
        (1, 1),
        "an unparked backspace still moves"
    );
}

/// The park is spent only under `?45`, which is the mode xterm gates it on — with reverse
/// wrap off a parked backspace moves like any other (#80).
#[test]
fn a_parked_backspace_without_reverse_wrap_still_moves() {
    let mut t = Engine::new(3, 2);
    t.feed(b"\x1b[?45l");
    t.feed(b"abcdef");
    assert!(t.cursor().pending_wrap, "precondition: parked");
    t.feed(b"\x08");
    assert_eq!((t.cursor().row, t.cursor().col), (1, 1));
}

/// With autowrap **off**, a parked backspace moves — the park is spent by moving, not by
/// standing still (#80).
///
/// This test asserted the opposite in a first version of this change, on a reading of
/// xterm's spend site that stopped one line too early. `cursor.c:153` gates on
/// `(rev || rev2) && screen->do_wrap`, and `rev` looks like the mode flag but is not:
/// `:123-127` define `WRAP_MASK (REVERSEWRAP | WRAPAROUND)` and
/// `rev = ((flags & WRAP_MASK) == WRAP_MASK)`, so it means *`?45` **and** `?7h`* and the
/// spend branch is dead under `?7l`. ghostty reaches the same answer earlier still —
/// `if (!self.modes.get(.wraparound)) break :wrap_mode .none;` (`Terminal.zig:1756`)
/// returns through the plain decrement at `:1766-1769`. xterm.js never reaches the state,
/// since its `?7l` print pins `x = cols - 1` (`InputHandler.ts:612`). 3-0.
///
/// Both ways of arriving at the park are covered, because they are different mechanisms:
/// armed under `?7l` (which only #869 made possible) and armed under `?7h` and then
/// carried into `?7l` (reachable long before it).
#[test]
fn a_parked_backspace_with_autowrap_off_moves() {
    // Armed with the mode already off.
    let mut fresh = Engine::new(3, 2);
    fresh.feed(b"\x1b[?7l\x1b[?45h");
    fresh.feed(b"abc");
    assert!(
        fresh.cursor().pending_wrap,
        "precondition: parked under ?7l"
    );
    fresh.feed(b"\x08");
    assert_eq!(
        (fresh.cursor().row, fresh.cursor().col),
        (0, 1),
        "?7l: the park is spent by moving"
    );

    // Armed while the mode was on, then carried across `?7l`.
    let mut carried = Engine::new(3, 2);
    carried.feed(b"\x1b[?45h");
    carried.feed(b"abc");
    carried.feed(b"\x1b[?7l");
    assert!(
        carried.cursor().pending_wrap,
        "precondition: park carried in"
    );
    carried.feed(b"\x08");
    assert_eq!(
        (carried.cursor().row, carried.cursor().col),
        (0, 1),
        "a park carried into ?7l is spent by moving too"
    );
}

/// `CSI D` spends the park as the first unit of its move, exactly as BS does (#873).
///
/// **Decided by the maintainer on 2026-09-08 against a 2-2 reference split, and theirs to
/// reverse.** xterm and ghostty route both verbs through one function and so spend on
/// either — xterm's `CursorBack` is reached from `CASE_BS` (`charproc.c:3703`) and
/// `CASE_CUB` (`:3933`) alike, both outside any conditional compilation; ghostty's
/// `backspace` is `cursorLeft(1)` (`Terminal.zig:1696`) and the spend lives in
/// `cursorLeft` under a *"to match xterm"* comment (`:1774-1777`).
///
/// xterm.js is the one that separates them, and **on purpose rather than by accident of
/// its clamp order** — which is what the record here said until #873 read it properly.
/// Its `backspace` carries *"Our implementation deviates from xterm on purpose"* over
/// four bullets, of which *"any cursor movement sequence keeps working as expected"* is
/// this axis (`InputHandler.ts:810-818`), and `cursorBackward` is a bare
/// `_moveCursor(-n, 0)` (`:976-979`). alacritty implements no `?45` at all.
///
/// What broke the tie: `XTREVWRAP` is xterm-invented (`ctlseqs.txt:952`) with no DEC text
/// above it, ADR-0004 makes xterm the tie-breaker for this layer, and reach is ~0 through
/// terminfo — `?45` appears in neither xterm's `terminfo` nor its `termcap`, and its
/// `reverseWrap` resource defaults to `False` (`charproc.c:468`). Nothing arrives here
/// except an application that wrote the sequence against xterm's own definition of it.
///
/// The control is the whole test: an unparked cursor at the same coordinate must still
/// move its full count, or this has simply disabled `CSI D`.
#[test]
fn cursor_left_spends_a_park() {
    for (seq, want) in [(&b"\x1b[D"[..], 3usize), (&b"\x1b[3D"[..], 1)] {
        let mut t = Engine::new(4, 2);
        t.feed(b"\x1b[?45h");
        t.feed(b"abcd"); // fills row 0, parked at column 3
        assert!(t.cursor().pending_wrap, "precondition: parked");
        t.feed(seq);
        assert_eq!(
            (t.cursor().row, t.cursor().col),
            (0, want),
            "CUB {seq:?} spent the park as its first unit"
        );
        assert!(
            !t.cursor().pending_wrap,
            "and the park is spent, not still owed"
        );
    }

    // Control: same coordinate, no park. The full count still moves.
    for (seq, want) in [(&b"\x1b[D"[..], 2usize), (&b"\x1b[3D"[..], 0)] {
        let mut t = Engine::new(4, 2);
        t.feed(b"\x1b[?45h");
        t.feed(b"abc"); // cursor at column 3, unparked
        assert!(!t.cursor().pending_wrap, "precondition: not parked");
        t.feed(seq);
        assert_eq!(
            (t.cursor().row, t.cursor().col),
            (0, want),
            "an unparked CUB {seq:?} moves its full count"
        );
    }
}

/// With autowrap **off** a parked `CSI D` moves, the same 3-0 answer the backspace half
/// carries (#80) — pinned here so the shared step cannot acquire a different gate for
/// one of its two callers.
#[test]
fn a_parked_cursor_left_with_autowrap_off_moves() {
    let mut t = Engine::new(3, 2);
    t.feed(b"\x1b[?7l\x1b[?45h");
    t.feed(b"abc");
    assert!(t.cursor().pending_wrap, "precondition: parked under ?7l");
    t.feed(b"\x1b[D");
    assert_eq!(
        (t.cursor().row, t.cursor().col),
        (0, 1),
        "?7l: the park is spent by moving"
    );
}

/// The walk needs autowrap as well as the mode, exactly as the spend does (#873).
///
/// xterm reaches both arms through one `rev`, which is
/// `(flags & WRAP_MASK) == WRAP_MASK` over `WRAP_MASK (REVERSEWRAP | WRAPAROUND)`
/// (`cursor.c:123-127`), so the walk at `:165` is dead under `?7l` just as the spend at
/// `:153` is; ghostty returns through the plain decrement at `Terminal.zig:1766-1769`
/// before reaching either. This engine gated only the spend, so a park armed under `?7h`
/// and carried into `?7l` walked a row where both references clamp.
///
/// Driven through both verbs, because they share one step and a future change could give
/// only one of them the gate.
#[test]
fn the_reverse_wrap_walk_needs_autowrap_too() {
    for seq in [&b"\x08"[..], &b"\x1b[D"[..]] {
        let mut t = Engine::new(3, 2);
        t.feed(b"\x1b[?45h");
        t.feed(b"abcd"); // row 0 soft-wraps while ?7h is still on
        t.feed(b"\x1b[?7l"); // autowrap off — the wrap link itself stays
        t.feed(b"\x1b[2;1H"); // (1,0), which also clears the park
        t.feed(seq);
        assert_eq!(
            (t.cursor().row, t.cursor().col),
            (1, 0),
            "?7l: {seq:?} clamps at column 0 rather than walking"
        );
    }
}

/// A reverse-wrap does not break the wrap link, so the logical line survives it (#873).
///
/// **The control is what makes this a defect rather than a preference**: the same visible
/// content reached *without* a reverse-wrap reads as one logical line, so clearing the
/// link made two buffers holding identical cells answer differently depending only on how
/// the cursor arrived — and a reflow to a wider grid kept them apart instead of healing it.
/// Undoing the cursor's trip across the boundary does not undo the boundary.
///
/// xterm writes no wrap flag anywhere in `CursorBack`, and ghostty only *reads*
/// `prev_row.wrap` (`Terminal.zig:1842-1843`). xterm.js's `line.isWrapped = false`
/// (`InputHandler.ts:823`) is the outlier this engine had copied.
#[test]
fn a_reverse_wrap_keeps_the_two_rows_one_logical_line() {
    let text = |e: &Engine| {
        e.viewport_logical_lines()
            .iter()
            .map(|l| l.text.clone())
            .collect::<Vec<_>>()
    };

    let mut t = Engine::new(3, 3);
    t.feed(b"\x1b[?45h");
    t.feed(b"abcd");
    t.feed(b"\x08\x08"); // to (1,0), then reverse-wrap to (0,2)
    t.feed(b"X"); // overwrite the cell the walk landed on
    assert_eq!(text(&t), vec!["abXd".to_string()]);

    // Same cells, reached by plain autowrap. It must read the same, before and after a
    // reflow that would join the rows if they were ever separate.
    let mut control = Engine::new(3, 3);
    control.feed(b"abXd");
    assert_eq!(
        text(&control),
        text(&t),
        "identical cells, identical reading"
    );
    t.resize(6, 3);
    control.resize(6, 3);
    assert_eq!(text(&control), text(&t), "and identical through a reflow");
}

/// Spending the park over a wide glyph leaves the cursor on the pair's **spacer**, not
/// on its lead (#80).
///
/// This is the one shape where the fix is visible as a destroyed glyph: the next print
/// lands on the spacer and blanks the lead beside it. It is nonetheless the reference
/// answer — ghostty lands on the spacer identically, and xterm.js reaches the same cell
/// and then blanks `x - 1` because `getWidth(x - 1) === 2` (`InputHandler.ts:537-539`),
/// producing the same row. Pinned because "correct" and "harmless" are different claims
/// and only the first is being made.
#[test]
fn a_spent_park_lands_on_a_wide_pairs_spacer() {
    let mut t = Engine::new(4, 2);
    t.feed(b"\x1b[?45h");
    t.feed("ab\u{4e00}".as_bytes()); // lead at column 2, spacer at column 3, parked
    assert!(t.cursor().pending_wrap, "precondition: parked");
    t.feed(b"\x08");
    assert_eq!((t.cursor().row, t.cursor().col), (0, 3), "on the spacer");
    t.feed(b"X");
    let row: String = (0..4).map(|c| t.grid().cell(0, c).c()).collect();
    assert_eq!(row, "ab X", "the print blanks the orphaned lead beside it");
}