mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-25 16:41:44 +00:00
libghostty: functions to detect and write until stream ground state
This adds new functions to both C and Zig to write VT data until the VT parser reaches a "ground" state. The ground state is when the parser/stream is stateless: between all partial UTF-8, OSC, CSI, etc. This lets embedders safely interleave custom VT sequences from multiple sources. A practical example is a standard terminal reading from a pty that is then doing custom APC or something mid-stream for their emulator client.
This commit is contained in:
@@ -54,17 +54,17 @@ extern "C" {
|
||||
*
|
||||
* ## Effects
|
||||
*
|
||||
* By default, the terminal sequence processing with ghostty_terminal_vt_write()
|
||||
* only process sequences that directly affect terminal state and
|
||||
* By default, terminal sequence processing with the VT write functions only
|
||||
* processes sequences that directly affect terminal state and
|
||||
* ignores sequences that have side effect behavior or require responses.
|
||||
* These sequences include things like bell characters, title changes, device
|
||||
* attributes queries, and more. To handle these sequences, the embedder
|
||||
* must configure "effects."
|
||||
*
|
||||
* Effects are callbacks that the terminal invokes in response to VT
|
||||
* sequences processed during ghostty_terminal_vt_write(). They let the
|
||||
* embedding application react to terminal-initiated events such as bell
|
||||
* characters, title changes, device status report responses, and more.
|
||||
* sequences processed during VT writes. They let the embedding application
|
||||
* react to terminal-initiated events such as bell characters, title changes,
|
||||
* device status report responses, and more.
|
||||
*
|
||||
* Each effect is registered with ghostty_terminal_set() using the
|
||||
* corresponding `GhosttyTerminalOption` identifier. A `NULL` value
|
||||
@@ -75,9 +75,10 @@ extern "C" {
|
||||
* back to their own application state without global variables.
|
||||
* You cannot specify different userdata for different callbacks.
|
||||
*
|
||||
* All callbacks are invoked synchronously during
|
||||
* ghostty_terminal_vt_write(). Callbacks **must not** call
|
||||
* ghostty_terminal_vt_write() on the same terminal (no reentrancy).
|
||||
* All callbacks are invoked synchronously during VT writes. Callbacks
|
||||
* **must not** call ghostty_terminal_vt_write() or
|
||||
* ghostty_terminal_vt_write_until_ground() on the same terminal
|
||||
* (no reentrancy).
|
||||
* And callbacks must be very careful to not block for too long or perform
|
||||
* expensive operations, since they are blocking further IO processing.
|
||||
*
|
||||
@@ -1138,8 +1139,8 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
*
|
||||
* Continuation bytes reconstruct an escape sequence or UTF-8 codepoint
|
||||
* which was unfinished at the end of the most recent
|
||||
* ghostty_terminal_vt_write() call. They are used automatically by terminal
|
||||
* snapshots and may also be exported directly with the continuation APIs.
|
||||
* VT write call. They are used automatically by terminal snapshots and may
|
||||
* also be exported directly with the continuation APIs.
|
||||
*
|
||||
* Tracking is disabled by default. A nonzero value enables tracking and
|
||||
* sets its byte limit. Passing NULL or a pointer to zero disables tracking.
|
||||
@@ -1337,9 +1338,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
/**
|
||||
* The terminal title as set by escape sequences (e.g. OSC 0/2).
|
||||
*
|
||||
* Returns a borrowed string. The pointer is valid until the next call
|
||||
* to ghostty_terminal_vt_write() or ghostty_terminal_reset(). An empty
|
||||
* string (len=0) is returned when no title has been set.
|
||||
* Returns a borrowed string. The pointer is valid until the next mutating
|
||||
* terminal call. An empty string (len=0) is returned when no title has been
|
||||
* set.
|
||||
*
|
||||
* Output type: GhosttyString *
|
||||
*/
|
||||
@@ -1349,9 +1350,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
* The terminal's current working directory as set by escape sequences
|
||||
* (e.g. OSC 7).
|
||||
*
|
||||
* Returns a borrowed string. The pointer is valid until the next call
|
||||
* to ghostty_terminal_vt_write() or ghostty_terminal_reset(). An empty
|
||||
* string (len=0) is returned when no pwd has been set.
|
||||
* Returns a borrowed string. The pointer is valid until the next mutating
|
||||
* terminal call. An empty string (len=0) is returned when no pwd has been
|
||||
* set.
|
||||
*
|
||||
* Output type: GhosttyString *
|
||||
*/
|
||||
@@ -1597,6 +1598,21 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
* Input/output type: GhosttyTerminalModeConfig *
|
||||
*/
|
||||
GHOSTTY_TERMINAL_DATA_MODE = 37,
|
||||
|
||||
/**
|
||||
* Whether VT processing is at ground.
|
||||
*
|
||||
* Ground is when the stream isn't in the middle of any type of sequence:
|
||||
* UTF-8, ESC, CSI, OSC, etc. It is the stateless point of the stream.
|
||||
*
|
||||
* This is useful to know because it is a point at which you can
|
||||
* safely insert out-of-band VT sequences. For example, while reading
|
||||
* from a pty if you want to make your own changes, you can wait until
|
||||
* the pty input reaches ground, then write yours.
|
||||
*
|
||||
* Output type: bool *
|
||||
*/
|
||||
GHOSTTY_TERMINAL_DATA_VT_GROUND = 38,
|
||||
GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyTerminalData;
|
||||
|
||||
@@ -1682,9 +1698,10 @@ GHOSTTY_API GhosttyResult ghostty_terminal_resize(GhosttyTerminal terminal,
|
||||
* The behavior of a NULL value is specific to each option and is
|
||||
* documented by the corresponding GhosttyTerminalOption value.
|
||||
*
|
||||
* Callbacks are invoked synchronously during ghostty_terminal_vt_write().
|
||||
* Callbacks must not call ghostty_terminal_vt_write() on the same
|
||||
* terminal (no reentrancy).
|
||||
* Callbacks are invoked synchronously during VT writes. Callbacks must not
|
||||
* call ghostty_terminal_vt_write() or
|
||||
* ghostty_terminal_vt_write_until_ground() on the same terminal
|
||||
* (no reentrancy).
|
||||
*
|
||||
* @param terminal The terminal handle (may be NULL, in which case this is a no-op)
|
||||
* @param option The option to set
|
||||
@@ -1722,6 +1739,38 @@ GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal,
|
||||
const uint8_t* data,
|
||||
size_t len);
|
||||
|
||||
/**
|
||||
* Write VT-encoded data, but only the shortest prefix needed to reach ground.
|
||||
*
|
||||
* Ground is when the stream isn't in the middle of any type of sequence:
|
||||
* UTF-8, ESC, CSI, OSC, etc. It is the stateless point of the stream.
|
||||
*
|
||||
* This is useful to know because it is a point at which you can
|
||||
* safely insert out-of-band VT sequences. For example, while reading
|
||||
* from a pty if you want to make your own changes, you can wait until
|
||||
* the pty input reaches ground, then write yours.
|
||||
*
|
||||
* If the stream is already at ground then this consumes nothing and returns
|
||||
* GHOSTTY_SUCCESS. On success, out_consumed is the number of bytes consumed
|
||||
* before reaching ground, including the byte that reaches it.
|
||||
* GHOSTTY_NO_VALUE means the full slice was consumed without reaching ground.
|
||||
*
|
||||
* @param terminal The terminal handle (must not be NULL)
|
||||
* @param data Pointer to the data to write, or NULL when len is zero
|
||||
* @param len Length of the data in bytes
|
||||
* @param[out] out_consumed Number of bytes consumed (must not be NULL)
|
||||
* @return GHOSTTY_SUCCESS if ground was reached, GHOSTTY_NO_VALUE if all input
|
||||
* was consumed without reaching ground, or GHOSTTY_INVALID_VALUE if
|
||||
* an argument is invalid
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
GHOSTTY_API GhosttyResult ghostty_terminal_vt_write_until_ground(
|
||||
GhosttyTerminal terminal,
|
||||
const uint8_t* data,
|
||||
size_t len,
|
||||
size_t* out_consumed);
|
||||
|
||||
/**
|
||||
* Write the terminal's replay-safe VT continuation to a callback writer.
|
||||
*
|
||||
@@ -1735,8 +1784,8 @@ GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal,
|
||||
* GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the
|
||||
* input that produced the continuation was written.
|
||||
*
|
||||
* The caller must serialize this operation with ghostty_terminal_vt_write()
|
||||
* and all other access to the same terminal.
|
||||
* The caller must serialize this operation with both VT write functions and
|
||||
* all other access to the same terminal.
|
||||
*
|
||||
* @param terminal Terminal to read from (must not be NULL)
|
||||
* @param writer Destination writer whose write callback must not be NULL
|
||||
|
||||
@@ -294,6 +294,7 @@ comptime {
|
||||
@export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" });
|
||||
@export(&c.terminal_set, .{ .name = "ghostty_terminal_set" });
|
||||
@export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" });
|
||||
@export(&c.terminal_vt_write_until_ground, .{ .name = "ghostty_terminal_vt_write_until_ground" });
|
||||
@export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" });
|
||||
@export(&c.terminal_compression_activity, .{ .name = "ghostty_terminal_compression_activity" });
|
||||
@export(&c.terminal_compress, .{ .name = "ghostty_terminal_compress" });
|
||||
|
||||
@@ -184,6 +184,7 @@ pub const terminal_reset = terminal.reset;
|
||||
pub const terminal_resize = terminal.resize;
|
||||
pub const terminal_set = terminal.set;
|
||||
pub const terminal_vt_write = terminal.vt_write;
|
||||
pub const terminal_vt_write_until_ground = terminal.vt_write_until_ground;
|
||||
pub const terminal_scroll_viewport = terminal.scroll_viewport;
|
||||
pub const terminal_compression_activity = terminal.compression_activity;
|
||||
pub const terminal_compress = terminal.compress;
|
||||
|
||||
@@ -697,6 +697,32 @@ pub fn vt_write(
|
||||
wrapper.stream.nextSlice(ptr[0..len]);
|
||||
}
|
||||
|
||||
pub fn vt_write_until_ground(
|
||||
terminal_: Terminal,
|
||||
ptr_: ?[*]const u8,
|
||||
len: usize,
|
||||
out_consumed_: ?*usize,
|
||||
) callconv(lib.calling_conv) Result {
|
||||
const out_consumed = out_consumed_ orelse return .invalid_value;
|
||||
out_consumed.* = 0;
|
||||
|
||||
const wrapper = terminal_ orelse return .invalid_value;
|
||||
const input: []const u8 = if (ptr_) |ptr|
|
||||
ptr[0..len]
|
||||
else if (len == 0)
|
||||
""
|
||||
else
|
||||
return .invalid_value;
|
||||
|
||||
if (wrapper.stream.nextSliceUntilGround(input)) |consumed| {
|
||||
out_consumed.* = consumed;
|
||||
return .success;
|
||||
}
|
||||
|
||||
out_consumed.* = len;
|
||||
return .no_value;
|
||||
}
|
||||
|
||||
pub const ContinuationWriteError = error{
|
||||
InvalidValue,
|
||||
WriteFailed,
|
||||
@@ -1328,6 +1354,7 @@ pub const TerminalData = enum(c_int) {
|
||||
scrollback_max_lines = 35,
|
||||
continuation_max_bytes = 36,
|
||||
mode = 37,
|
||||
vt_ground = 38,
|
||||
|
||||
/// Output type expected for querying the data of the given kind.
|
||||
pub fn OutType(comptime self: TerminalData) type {
|
||||
@@ -1339,6 +1366,7 @@ pub const TerminalData = enum(c_int) {
|
||||
.mouse_tracking,
|
||||
.viewport_active,
|
||||
.vt_processing_error,
|
||||
.vt_ground,
|
||||
=> bool,
|
||||
.active_screen => TerminalScreen,
|
||||
.kitty_keyboard_flags => u8,
|
||||
@@ -1489,6 +1517,7 @@ fn getTyped(
|
||||
),
|
||||
.viewport_active => out.* = t.screens.active.pages.viewport == .active,
|
||||
.vt_processing_error => out.* = wrapper.stream.handler.semantic_failure,
|
||||
.vt_ground => out.* = wrapper.stream.ground(),
|
||||
.scrollback_max_bytes => {
|
||||
const max = t.screens.get(.primary).?.pages.limits.bytes.explicit;
|
||||
if (max == std.math.maxInt(usize)) return .no_value;
|
||||
@@ -2498,6 +2527,189 @@ test "vt_write" {
|
||||
try testing.expectEqualStrings("Hello", str);
|
||||
}
|
||||
|
||||
test "vt_write_until_ground result contract" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
// Input remains untouched when the stream is already at ground.
|
||||
var consumed: usize = 99;
|
||||
const untouched = "unprocessed";
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
vt_write_until_ground(t, untouched, untouched.len, &consumed),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 0), consumed);
|
||||
|
||||
// Complete a split CSI and stop before inspecting the printable suffix.
|
||||
vt_write(t, "\x1b[31", 4);
|
||||
const input = "mABC\x1b[";
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
vt_write_until_ground(t, input, input.len, &consumed),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 1), consumed);
|
||||
try testing.expect(t.?.stream.ground());
|
||||
|
||||
var str = try t.?.terminal.plainString(testing.allocator);
|
||||
try testing.expectEqualStrings("", str);
|
||||
testing.allocator.free(str);
|
||||
|
||||
// The untouched suffix can be processed after work at the boundary.
|
||||
vt_write(t, input.ptr + consumed, input.len - consumed);
|
||||
str = try t.?.terminal.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("ABC", str);
|
||||
try testing.expect(!t.?.stream.ground());
|
||||
|
||||
// Exhausting input while unfinished is distinct from reaching ground on
|
||||
// the final byte, even though both consume the entire input.
|
||||
try testing.expectEqual(
|
||||
Result.no_value,
|
||||
vt_write_until_ground(t, "123", 3, &consumed),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 3), consumed);
|
||||
try testing.expect(!t.?.stream.ground());
|
||||
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
vt_write_until_ground(t, "m", 1, &consumed),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 1), consumed);
|
||||
try testing.expect(t.?.stream.ground());
|
||||
}
|
||||
|
||||
test "vt_write_until_ground handles UTF-8 and abort boundaries" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
var ground: bool = false;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
get(t, .vt_ground, @ptrCast(&ground)),
|
||||
);
|
||||
try testing.expect(ground);
|
||||
|
||||
vt_write(t, &.{0xF0}, 1);
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
get(t, .vt_ground, @ptrCast(&ground)),
|
||||
);
|
||||
try testing.expect(!ground);
|
||||
|
||||
const utf8_suffix = [_]u8{ 0x9F, 0x98, 0x84 };
|
||||
var consumed: usize = 99;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
vt_write_until_ground(t, &utf8_suffix, utf8_suffix.len, &consumed),
|
||||
);
|
||||
try testing.expectEqual(utf8_suffix.len, consumed);
|
||||
try testing.expect(t.?.stream.ground());
|
||||
|
||||
// A malformed continuation resets the decoder and reaches ground after
|
||||
// processing the retry byte.
|
||||
vt_write(t, &.{ 0xE0, 0xA0 }, 2);
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
vt_write_until_ground(t, "A", 1, &consumed),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 1), consumed);
|
||||
try testing.expect(t.?.stream.ground());
|
||||
|
||||
vt_write(t, "\x1b[123", 5);
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
vt_write_until_ground(t, &.{0x18}, 1, &consumed),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 1), consumed);
|
||||
try testing.expect(t.?.stream.ground());
|
||||
}
|
||||
|
||||
test "vt_write_until_ground invokes effects only for consumed input" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
const S = struct {
|
||||
var bell_count: usize = 0;
|
||||
|
||||
fn bell(_: Terminal, _: ?*anyopaque) callconv(lib.calling_conv) void {
|
||||
bell_count += 1;
|
||||
}
|
||||
};
|
||||
S.bell_count = 0;
|
||||
try testing.expectEqual(Result.success, set(t, .bell, @ptrCast(&S.bell)));
|
||||
|
||||
vt_write(t, "\x1b[31", 4);
|
||||
const input = "m\x07";
|
||||
var consumed: usize = 99;
|
||||
try testing.expectEqual(
|
||||
Result.success,
|
||||
vt_write_until_ground(t, input, input.len, &consumed),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 1), consumed);
|
||||
try testing.expectEqual(@as(usize, 0), S.bell_count);
|
||||
|
||||
vt_write(t, input.ptr + consumed, input.len - consumed);
|
||||
try testing.expectEqual(@as(usize, 1), S.bell_count);
|
||||
}
|
||||
|
||||
test "vt_write_until_ground validates arguments" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
80,
|
||||
24,
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
var consumed: usize = 99;
|
||||
try testing.expectEqual(
|
||||
Result.invalid_value,
|
||||
vt_write_until_ground(null, "x", 1, &consumed),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 0), consumed);
|
||||
|
||||
consumed = 99;
|
||||
try testing.expectEqual(
|
||||
Result.invalid_value,
|
||||
vt_write_until_ground(t, null, 1, &consumed),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 0), consumed);
|
||||
|
||||
try testing.expectEqual(
|
||||
Result.invalid_value,
|
||||
vt_write_until_ground(t, "", 0, null),
|
||||
);
|
||||
|
||||
// NULL represents a valid empty slice. While unfinished it consumes zero
|
||||
// bytes and reports that no ground boundary was found.
|
||||
vt_write(t, "\x1b[", 2);
|
||||
consumed = 99;
|
||||
try testing.expectEqual(
|
||||
Result.no_value,
|
||||
vt_write_until_ground(t, null, 0, &consumed),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 0), consumed);
|
||||
}
|
||||
|
||||
test "vt_write split escape sequence" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
|
||||
@@ -595,6 +595,56 @@ pub fn Stream(comptime H: type) type {
|
||||
if (self.continuation != null) self.trackContinuation(input);
|
||||
}
|
||||
|
||||
/// Process a string of characters, but only the shortest prefix
|
||||
/// needed to reach the ground state.
|
||||
///
|
||||
/// The ground state is when the stream isn't in the middle of any
|
||||
/// type of sequence: UTF-8, ESC, CSI, OSC, etc. It is the stateless
|
||||
/// point of the stream.
|
||||
///
|
||||
/// If the stream is already at ground then this consumes nothing
|
||||
/// and returns zero. A non-null return is the number of bytes consumed
|
||||
/// before reaching ground, including the byte that reaches it. A null
|
||||
/// return means the full slice was consumed without reaching ground.
|
||||
///
|
||||
/// This is anywhere from 1% to 5% slower than nextSlice, depending
|
||||
/// on the types of inputs provided (e.g. OSC vs APC, whether it
|
||||
/// ever reaches ground, etc.).
|
||||
pub inline fn nextSliceUntilGround(
|
||||
self: *Self,
|
||||
input: []const u8,
|
||||
) ?usize {
|
||||
const consumed = self.nextSliceUntilGroundUntracked(input);
|
||||
const consumed_len = consumed orelse input.len;
|
||||
if (self.continuation != null and consumed_len > 0) {
|
||||
self.trackContinuation(input[0..consumed_len]);
|
||||
}
|
||||
|
||||
return consumed;
|
||||
}
|
||||
|
||||
inline fn nextSliceUntilGroundUntracked(
|
||||
self: *Self,
|
||||
input: []const u8,
|
||||
) ?usize {
|
||||
if (self.ground()) return 0;
|
||||
|
||||
// Process UTF-8 if we're within that state.
|
||||
var offset: usize = 0;
|
||||
while (self.utf8decoder.state != 0) {
|
||||
if (offset >= input.len) return null;
|
||||
self.nextUtf8(input[offset]);
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
// Process non-UTF-8
|
||||
if (self.parser.state != .ground) {
|
||||
offset += self.consumeUntilGround(input[offset..]);
|
||||
}
|
||||
|
||||
return if (self.ground()) offset else null;
|
||||
}
|
||||
|
||||
inline fn nextSliceUntracked(self: *Self, input: []const u8) void {
|
||||
// Disable SIMD optimizations if build requests it or if our
|
||||
// manual debug mode is on.
|
||||
@@ -4161,6 +4211,152 @@ const ContinuationNullHandler = struct {
|
||||
) void {}
|
||||
};
|
||||
|
||||
test "stream: nextSliceUntilGround stops at the earliest boundary" {
|
||||
const S = Stream(ContinuationTestHandler);
|
||||
var stream: S = .init(.{ .handler = .{} });
|
||||
defer stream.deinit();
|
||||
|
||||
stream.nextSlice("\x1b[31");
|
||||
try testing.expect(!stream.ground());
|
||||
stream.handler.committed = 0;
|
||||
|
||||
const input = "mABC\x1b[";
|
||||
const consumed = stream.nextSliceUntilGround(input).?;
|
||||
try testing.expectEqual(@as(usize, 1), consumed);
|
||||
try testing.expect(stream.ground());
|
||||
try testing.expectEqual(@as(usize, 1), stream.handler.committed);
|
||||
|
||||
// The suffix was not inspected by the handler and can be processed after
|
||||
// the caller performs work at the boundary.
|
||||
stream.nextSlice(input[consumed..]);
|
||||
try testing.expect(!stream.ground());
|
||||
try testing.expectEqual(Parser.State.csi_entry, stream.parser.state);
|
||||
try testing.expectEqual(@as(usize, 4), stream.handler.committed);
|
||||
}
|
||||
|
||||
test "stream: nextSliceUntilGround consumes all input without a boundary" {
|
||||
const S = Stream(ContinuationNullHandler);
|
||||
var stream: S = .init(.{ .handler = .{} });
|
||||
defer stream.deinit();
|
||||
|
||||
try testing.expectEqual(
|
||||
@as(?usize, 0),
|
||||
stream.nextSliceUntilGround("unprocessed"),
|
||||
);
|
||||
try testing.expect(stream.ground());
|
||||
|
||||
stream.nextSlice("\x1b[");
|
||||
try testing.expectEqual(
|
||||
@as(?usize, null),
|
||||
stream.nextSliceUntilGround("123"),
|
||||
);
|
||||
try testing.expect(!stream.ground());
|
||||
|
||||
// A boundary on the final byte is distinguishable from exhausting the
|
||||
// input while still pending.
|
||||
try testing.expectEqual(
|
||||
@as(?usize, 1),
|
||||
stream.nextSliceUntilGround("m"),
|
||||
);
|
||||
try testing.expect(stream.ground());
|
||||
}
|
||||
|
||||
test "stream: nextSliceUntilGround handles UTF-8 boundaries" {
|
||||
const S = Stream(ContinuationTestHandler);
|
||||
|
||||
// A completed codepoint is committed synchronously, and the printable
|
||||
// suffix remains untouched.
|
||||
var valid: S = .init(.{ .handler = .{} });
|
||||
defer valid.deinit();
|
||||
valid.nextSlice(&.{0xF0});
|
||||
const valid_input = [_]u8{ 0x9F, 0x98, 0x84, 'X' };
|
||||
try testing.expectEqual(
|
||||
@as(?usize, 3),
|
||||
valid.nextSliceUntilGround(&valid_input),
|
||||
);
|
||||
try testing.expect(valid.ground());
|
||||
try testing.expectEqual(@as(usize, 1), valid.handler.committed);
|
||||
|
||||
// A malformed continuation emits the replacement codepoint and retries
|
||||
// the same byte. Ground is observed after that complete byte operation.
|
||||
var malformed: S = .init(.{ .handler = .{} });
|
||||
defer malformed.deinit();
|
||||
malformed.nextSlice(&.{ 0xE0, 0xA0 });
|
||||
try testing.expectEqual(
|
||||
@as(?usize, 1),
|
||||
malformed.nextSliceUntilGround("A!"),
|
||||
);
|
||||
try testing.expect(malformed.ground());
|
||||
try testing.expectEqual(@as(usize, 2), malformed.handler.committed);
|
||||
|
||||
// If the retried byte is ESC, the stream has begun VT state at the end of
|
||||
// that byte and must continue to the following ground boundary.
|
||||
var retry_escape: S = .init(.{ .handler = .{} });
|
||||
defer retry_escape.deinit();
|
||||
retry_escape.nextSlice(&.{ 0xE0, 0xA0 });
|
||||
try testing.expectEqual(
|
||||
@as(?usize, 3),
|
||||
retry_escape.nextSliceUntilGround("\x1b[mX"),
|
||||
);
|
||||
try testing.expect(retry_escape.ground());
|
||||
}
|
||||
|
||||
test "stream: nextSliceUntilGround handles aborts and bulk strings" {
|
||||
const S = Stream(ContinuationTestHandler);
|
||||
|
||||
var aborted: S = .init(.{ .handler = .{} });
|
||||
defer aborted.deinit();
|
||||
aborted.nextSlice("\x1b[123");
|
||||
try testing.expectEqual(
|
||||
@as(?usize, 1),
|
||||
aborted.nextSliceUntilGround(&.{ 0x18, 'X' }),
|
||||
);
|
||||
try testing.expect(aborted.ground());
|
||||
|
||||
var apc: S = .init(.{ .handler = .{} });
|
||||
defer apc.deinit();
|
||||
apc.nextSlice("\x1b_Gseed");
|
||||
var input: [131]u8 = undefined;
|
||||
@memset(input[0..128], 'a');
|
||||
input[128..130].* = "\x1b\\".*;
|
||||
input[130] = 'X';
|
||||
try testing.expectEqual(
|
||||
@as(?usize, 130),
|
||||
apc.nextSliceUntilGround(&input),
|
||||
);
|
||||
try testing.expect(apc.ground());
|
||||
}
|
||||
|
||||
test "stream: nextSliceUntilGround tracks only the consumed prefix" {
|
||||
const S = Stream(ContinuationNullHandler);
|
||||
var stream: S = .init(.{
|
||||
.allocator = testing.allocator,
|
||||
.handler = .{},
|
||||
.continuation_max_bytes = 64,
|
||||
});
|
||||
defer stream.deinit();
|
||||
|
||||
stream.nextSlice("\x1b[31");
|
||||
try testing.expectEqual(
|
||||
@as(?usize, 1),
|
||||
stream.nextSliceUntilGround("mX\x1b["),
|
||||
);
|
||||
|
||||
var buf: [64]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try stream.writeContinuation(&writer);
|
||||
try testing.expectEqual(@as(usize, 0), writer.buffered().len);
|
||||
|
||||
stream.nextSlice("\x1b[");
|
||||
try testing.expectEqual(
|
||||
@as(?usize, null),
|
||||
stream.nextSliceUntilGround("123"),
|
||||
);
|
||||
var pending_writer: std.Io.Writer = .fixed(&buf);
|
||||
try stream.writeContinuation(&pending_writer);
|
||||
try testing.expectEqualStrings("\x1b[123", pending_writer.buffered());
|
||||
}
|
||||
|
||||
test "stream: continuation lifecycle" {
|
||||
const S = Stream(ContinuationTestHandler);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user