Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions src/cursor_dsr.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
//! DSR-6n (Device Status Report — cursor position) reply interceptor.
//!
//! `\x1B[6n` is the standard ECMA-48 / VT100 query: the terminal
//! replies on stdin with `\x1B[<row>;<col>R`. atty uses this to
//! ground-truth the cursor at sensitive moments (inline panel open,
//! SIGWINCH, post-command `;D`) when the passive `cursor_tracker`
//! model might have drifted. Reply parsing has to happen BEFORE the
//! stdin bytes reach the keymap / dispatchInput pipeline — otherwise
//! the reply gets forwarded to bash as if the user typed `[24;1R`.
//!
//! ## State machine
//!
//! Same shape as `osc133.zig`'s parser: walk bytes, drop into a
//! `csi_reply` state on `\x1B[`, accumulate digits + `;`, terminate
//! on `R` (success) or anything outside the accepted alphabet (abort
//! and pass through). Bytes that PARTICIPATE in a successful reply
//! are CONSUMED — `consumed_indices` records them so the caller can
//! filter the input slice.
//!
//! ## Scope of "consumed"
//!
//! We only consume bytes when we matched the FULL `\x1B[<digits>;<digits>R`
//! shape. Partial-match abandonment (e.g. user types `\x1B[A` for
//! Up-arrow — same prefix but different final) leaves the bytes in
//! the original stream so legacy keymap matching still works. The
//! consequence: a DSR reply gets parsed only when it's complete in
//! one chunk OR carries over correctly across two chunks (the
//! `pending` state handles that). Mid-stream partial matches that
//! abandon don't get retroactively re-fed.
//!
//! ## Non-goals
//!
//! - Multi-reply pipelining. atty fires DSR at most once per "key
//! moment" and waits for the reply (or times out) before firing
//! another. The parser only handles one in-flight reply at a time.
//! - Other DSR variants (DSR-5 status, DSR-15 printer). Not used.

const std = @import("std");

pub const Position = struct { row: u16, col: u16 };

pub const DsrParser = struct {
state: State = .ground,
digits_buf: [16]u8 = undefined,
digits_len: u8 = 0,
row: u16 = 0,
col: u16 = 0,
/// Bytes accumulated while we're parsing a possible reply.
/// They DON'T appear in `feed`'s output until either:
/// - the reply completes successfully → drop the whole buffer
/// - the sequence aborts → flush the buffer to output verbatim
/// Withholding here is required for cross-chunk splits: a reply
/// like `\x1B[12;` (chunk 1) + `45R` (chunk 2) would otherwise
/// leak the 5 chunk-1 bytes to bash before we know it's a reply.
/// 32 bytes covers the largest legitimate DSR reply (`\x1B[<5
/// digits>;<5 digits>R` = 13) with comfort.
pending_buf: [32]u8 = undefined,
pending_len: u8 = 0,

const State = enum { ground, esc, csi, row_done };

/// Build the output slice by filtering the input through the
/// parser. Bytes that BELONG to a successful DSR reply are
/// dropped; everything else is preserved. Returns the position
/// parsed (if any) and the filtered byte count.
///
/// `out` must be at least `input.len` bytes. Caller passes a
/// scratch buffer they're already writing to.
///
/// Bytes that are part of an in-flight (not yet
/// completed-or-aborted) sequence are NOT written to `out` —
/// they live in the parser's internal `pending_buf` and either
/// vanish (success) or get flushed (abort) on a later feed call.
pub fn feed(self: *DsrParser, input: []const u8, out: []u8) struct { filtered_len: usize, pos: ?Position } {
var w: usize = 0;
var result_pos: ?Position = null;

for (input) |b| {
switch (self.state) {
.ground => {
if (b == 0x1B) {
self.state = .esc;
self.pushPending(b);
} else {
out[w] = b;
w += 1;
}
},
.esc => {
if (b == '[') {
self.state = .csi;
self.digits_len = 0;
self.row = 0;
self.col = 0;
self.pushPending(b);
} else {
// Not the shape we want — flush pending +
// current byte verbatim so keymap matchers
// downstream see the original sequence.
w += self.flushPending(out[w..]);
out[w] = b;
w += 1;
self.state = .ground;
}
},
.csi => {
if (b >= '0' and b <= '9' and self.digits_len < self.digits_buf.len) {
self.digits_buf[self.digits_len] = b;
self.digits_len += 1;
self.pushPending(b);
} else if (b == ';') {
self.row = parseClamped(self.digits_buf[0..self.digits_len]);
self.digits_len = 0;
self.state = .row_done;
self.pushPending(b);
} else {
// Anything else aborts (including a stray
// 'R' with no `;` — malformed reply). Flush
// pending + the abort byte verbatim.
w += self.flushPending(out[w..]);
out[w] = b;
w += 1;
self.state = .ground;
}
},
.row_done => {
if (b >= '0' and b <= '9' and self.digits_len < self.digits_buf.len) {
self.digits_buf[self.digits_len] = b;
self.digits_len += 1;
self.pushPending(b);
} else if (b == 'R') {
// Full reply received — drop pending buffer
// (the DSR sequence is consumed).
self.col = parseClamped(self.digits_buf[0..self.digits_len]);
result_pos = .{ .row = self.row, .col = self.col };
self.pending_len = 0;
self.state = .ground;
self.digits_len = 0;
} else {
// Malformed (no terminator) — abandon, flush
// pending + the abort byte.
w += self.flushPending(out[w..]);
out[w] = b;
w += 1;
self.state = .ground;
}
},
}
}

return .{ .filtered_len = w, .pos = result_pos };
}

fn pushPending(self: *DsrParser, b: u8) void {
if (self.pending_len < self.pending_buf.len) {
self.pending_buf[self.pending_len] = b;
self.pending_len += 1;
}
// Overflow: silently drop bytes past the buffer. A
// legitimate DSR is ≤ 13 bytes; if we overflow we're
// looking at a hostile / malformed sequence that wasn't
// going to complete cleanly anyway.
}

fn flushPending(self: *DsrParser, out: []u8) usize {
const n: usize = self.pending_len;
if (n > 0) @memcpy(out[0..n], self.pending_buf[0..n]);
self.pending_len = 0;
return n;
}

/// Emit the DSR-6n query sequence into `w`. Caller writes the
/// result to STDOUT; the terminal replies on stdin which `feed`
/// will parse.
pub fn writeQuery(w: *std.Io.Writer) !void {
try w.writeAll("\x1B[6n");
}
};

fn parseClamped(digits: []const u8) u16 {
var acc: u32 = 0;
const cap: u32 = std.math.maxInt(u16);
for (digits) |b| {
if (b < '0' or b > '9') continue;
if (acc >= cap) {
acc = cap;
continue;
}
const d: u32 = b - '0';
const next: u64 = @as(u64, acc) * 10 + d;
acc = if (next > cap) cap else @intCast(next);
}
return @intCast(acc);
}

test {
_ = @import("cursor_dsr_tests.zig");
}
159 changes: 159 additions & 0 deletions src/cursor_dsr_tests.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
//! Tests for `cursor_dsr.zig` — the DSR-6n reply interceptor.

const std = @import("std");
const testing = std.testing;
const mod = @import("cursor_dsr.zig");

const DsrParser = mod.DsrParser;

test "DsrParser: full reply in one chunk is consumed; position returned" {
var p = DsrParser{};
var out: [64]u8 = undefined;
const r = p.feed("\x1B[24;80R", &out);
try testing.expectEqual(@as(usize, 0), r.filtered_len);
try testing.expect(r.pos != null);
try testing.expectEqual(@as(u16, 24), r.pos.?.row);
try testing.expectEqual(@as(u16, 80), r.pos.?.col);
}

test "DsrParser: reply embedded between printable bytes — only reply consumed" {
var p = DsrParser{};
var out: [64]u8 = undefined;
const r = p.feed("a\x1B[10;5Rb", &out);
try testing.expect(r.pos != null);
try testing.expectEqual(@as(u16, 10), r.pos.?.row);
try testing.expectEqual(@as(u16, 5), r.pos.?.col);
try testing.expectEqualStrings("ab", out[0..r.filtered_len]);
}

test "DsrParser: unrelated CSI (`\\x1b[A` — Up arrow) passes through" {
var p = DsrParser{};
var out: [64]u8 = undefined;
const r = p.feed("\x1B[A", &out);
try testing.expect(r.pos == null);
try testing.expectEqualStrings("\x1B[A", out[0..r.filtered_len]);
}

test "DsrParser: CSI with single param ending in `R` (no `;`) doesn't match" {
// A real DSR reply always has row + col separated by `;`. A
// sequence like `\x1B[24R` is malformed — pass through.
var p = DsrParser{};
var out: [64]u8 = undefined;
const r = p.feed("\x1B[24R", &out);
try testing.expect(r.pos == null);
try testing.expectEqualStrings("\x1B[24R", out[0..r.filtered_len]);
}

test "DsrParser: reply split across two feeds — NEITHER chunk leaks bytes" {
// Critical invariant: pending bytes stay in the parser's
// internal buffer until completion (drop) or abort (flush).
// A naive implementation would write `\x1B[12;` into the first
// chunk's filtered output and forward to bash before learning
// it was a reply.
var p = DsrParser{};
var out: [64]u8 = undefined;
const r1 = p.feed("\x1B[12;", &out);
try testing.expect(r1.pos == null);
try testing.expectEqual(@as(usize, 0), r1.filtered_len);

var out2: [64]u8 = undefined;
const r2 = p.feed("45R", &out2);
try testing.expect(r2.pos != null);
try testing.expectEqual(@as(u16, 12), r2.pos.?.row);
try testing.expectEqual(@as(u16, 45), r2.pos.?.col);
try testing.expectEqual(@as(usize, 0), r2.filtered_len);
}

test "DsrParser: split reply with user bytes BEFORE the tail — user bytes preserved" {
// Pathological case: chunk 1 starts a reply, chunk 2 contains
// user keystrokes BEFORE the reply completes (the user typed
// while the terminal queued the DSR response). The user bytes
// would abort the reply parse and must reach bash; the
// pending-buffer flush emits the partial-reply bytes
// VERBATIM so keymap matchers still see them.
var p = DsrParser{};
var out: [64]u8 = undefined;
const r1 = p.feed("\x1B[12;", &out);
try testing.expectEqual(@as(usize, 0), r1.filtered_len);

// Chunk 2 has user input that aborts the pending reply.
var out2: [64]u8 = undefined;
const r2 = p.feed("xls\r", &out2);
try testing.expect(r2.pos == null);
// Aborted: the pending `\x1B[12;` flushes verbatim followed by
// `xls\r`.
try testing.expectEqualStrings("\x1B[12;xls\r", out2[0..r2.filtered_len]);
}

test "DsrParser: in-flight pending across an idle feed call — bytes still withheld" {
var p = DsrParser{};
var out: [64]u8 = undefined;
const r1 = p.feed("\x1B[", &out);
try testing.expectEqual(@as(usize, 0), r1.filtered_len);

// Empty feed (nothing arrives this tick).
const r2 = p.feed("", &out);
try testing.expectEqual(@as(usize, 0), r2.filtered_len);
try testing.expect(r2.pos == null);

// Reply finishes later.
const r3 = p.feed("1;2R", &out);
try testing.expect(r3.pos != null);
try testing.expectEqual(@as(u16, 1), r3.pos.?.row);
try testing.expectEqual(@as(u16, 2), r3.pos.?.col);
try testing.expectEqual(@as(usize, 0), r3.filtered_len);
}

test "DsrParser: zero-param fields parse as 0 (caller's job to clamp)" {
var p = DsrParser{};
var out: [64]u8 = undefined;
const r = p.feed("\x1B[;R", &out);
try testing.expect(r.pos != null);
try testing.expectEqual(@as(u16, 0), r.pos.?.row);
try testing.expectEqual(@as(u16, 0), r.pos.?.col);
}

test "DsrParser: massive digit values saturate at u16 max" {
var p = DsrParser{};
var out: [64]u8 = undefined;
const r = p.feed("\x1B[99999;88888R", &out);
try testing.expect(r.pos != null);
try testing.expectEqual(@as(u16, std.math.maxInt(u16)), r.pos.?.row);
try testing.expectEqual(@as(u16, std.math.maxInt(u16)), r.pos.?.col);
}

test "DsrParser: writeQuery emits the standard sequence" {
var buf: [16]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try DsrParser.writeQuery(&w);
try testing.expectEqualStrings("\x1B[6n", buf[0..w.end]);
}

test "DsrParser: two replies in a single chunk both parse" {
var p = DsrParser{};
var out: [64]u8 = undefined;
const r1 = p.feed("\x1B[1;2R", &out);
try testing.expect(r1.pos != null);
try testing.expectEqual(@as(u16, 1), r1.pos.?.row);
try testing.expectEqual(@as(u16, 2), r1.pos.?.col);

var out2: [64]u8 = undefined;
const r2 = p.feed("\x1B[3;4R", &out2);
try testing.expect(r2.pos != null);
try testing.expectEqual(@as(u16, 3), r2.pos.?.row);
try testing.expectEqual(@as(u16, 4), r2.pos.?.col);
}

test "DsrParser: abort mid-CSI (`\\x1b[12;abc`) restores byte stream verbatim" {
// If the user types something that LOOKS like the start of a
// DSR reply but isn't, the parser must release the bytes so
// keymap matching downstream still works.
var p = DsrParser{};
var out: [64]u8 = undefined;
const r = p.feed("\x1B[12;a", &out);
try testing.expect(r.pos == null);
// The chunk contained `\x1B[12;` (5 bytes accumulated as
// pending) + `a` (abort). After abort the parser should have
// released all 6 bytes through the output buffer.
try testing.expectEqualStrings("\x1B[12;a", out[0..r.filtered_len]);
}
Loading
Loading