terminal: keep track of colon vs semicolon state in CSI params

Fixes #5022

The CSI SGR sequence (CSI m) is unique in that its the only CSI sequence
that allows colons as delimiters between some parameters, and the colon
vs. semicolon changes the semantics of the parameters.

Previously, Ghostty assumed that an SGR sequence was either all colons
or all semicolons, and would change its behavior based on the first
delimiter it encountered.

This is incorrect. It is perfectly valid for an SGR sequence to have
both colons and semicolons as delimiters. For example, Kakoune sends
the following:

    ;4:3;38;2;175;175;215;58:2::190:80:70m

This is equivalent to:

  - unset (0)
  - curly underline (4:3)
  - foreground color (38;2;175;175;215)
  - underline color (58:2::190:80:70)

This commit changes the behavior of Ghostty to track the delimiter per
parameter, rather than per sequence. It also updates the SGR parser to
be more robust and handle the various edge cases that can occur. Tests
were added for the new cases.
This commit is contained in:
Mitchell Hashimoto
2025-01-13 10:52:29 -08:00
parent 132c4f1f68
commit 7aed08be40
3 changed files with 450 additions and 200 deletions

View File

@ -6,6 +6,7 @@ const Parser = @This();
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const testing = std.testing;
const table = @import("parse_table.zig").table;
const osc = @import("osc.zig");
@ -81,11 +82,15 @@ pub const Action = union(enum) {
pub const CSI = struct {
intermediates: []u8,
params: []u16,
params_sep: SepList,
final: u8,
sep: Sep,
/// The list of separators used for CSI params. The value of the
/// bit can be mapped to Sep.
pub const SepList = std.StaticBitSet(MAX_PARAMS);
/// The separator used for CSI params.
pub const Sep = enum { semicolon, colon };
pub const Sep = enum(u1) { semicolon = 0, colon = 1 };
// Implement formatter for logging
pub fn format(
@ -183,15 +188,6 @@ pub const Action = union(enum) {
}
};
/// Keeps track of the parameter sep used for CSI params. We allow colons
/// to be used ONLY by the 'm' CSI action.
pub const ParamSepState = enum(u8) {
none = 0,
semicolon = ';',
colon = ':',
mixed = 1,
};
/// Maximum number of intermediate characters during parsing. This is
/// 4 because we also use the intermediates array for UTF8 decoding which
/// can be at most 4 bytes.
@ -207,8 +203,8 @@ intermediates_idx: u8 = 0,
/// Param tracking, building
params: [MAX_PARAMS]u16 = undefined,
params_sep: Action.CSI.SepList = Action.CSI.SepList.initEmpty(),
params_idx: u8 = 0,
params_sep: ParamSepState = .none,
param_acc: u16 = 0,
param_acc_idx: u8 = 0,
@ -312,13 +308,9 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
// Ignore too many parameters
if (self.params_idx >= MAX_PARAMS) break :param null;
// If this is our first time seeing a parameter, we track
// the separator used so that we can't mix separators later.
if (self.params_idx == 0) self.params_sep = @enumFromInt(c);
if (@as(ParamSepState, @enumFromInt(c)) != self.params_sep) self.params_sep = .mixed;
// Set param final value
self.params[self.params_idx] = self.param_acc;
if (c == ':') self.params_sep.set(self.params_idx);
self.params_idx += 1;
// Reset current param value to 0
@ -359,29 +351,18 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
.csi_dispatch = .{
.intermediates = self.intermediates[0..self.intermediates_idx],
.params = self.params[0..self.params_idx],
.params_sep = self.params_sep,
.final = c,
.sep = switch (self.params_sep) {
.none, .semicolon => .semicolon,
.colon => .colon,
// There is nothing that treats mixed separators specially
// afaik so we just treat it as a semicolon.
.mixed => .semicolon,
},
},
};
// We only allow colon or mixed separators for the 'm' command.
switch (self.params_sep) {
.none => {},
.semicolon => {},
.colon, .mixed => if (c != 'm') {
if (c != 'm' and self.params_sep.count() > 0) {
log.warn(
"CSI colon or mixed separators only allowed for 'm' command, got: {}",
.{result},
);
break :csi_dispatch null;
},
}
break :csi_dispatch result;
@ -400,7 +381,7 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
pub fn clear(self: *Parser) void {
self.intermediates_idx = 0;
self.params_idx = 0;
self.params_sep = .none;
self.params_sep = Action.CSI.SepList.initEmpty();
self.param_acc = 0;
self.param_acc_idx = 0;
}
@ -507,10 +488,11 @@ test "csi: SGR ESC [ 38 : 2 m" {
const d = a[1].?.csi_dispatch;
try testing.expect(d.final == 'm');
try testing.expect(d.sep == .colon);
try testing.expect(d.params.len == 2);
try testing.expectEqual(@as(u16, 38), d.params[0]);
try testing.expect(d.params_sep.isSet(0));
try testing.expectEqual(@as(u16, 2), d.params[1]);
try testing.expect(!d.params_sep.isSet(1));
}
}
@ -581,13 +563,17 @@ test "csi: SGR ESC [ 48 : 2 m" {
const d = a[1].?.csi_dispatch;
try testing.expect(d.final == 'm');
try testing.expect(d.sep == .colon);
try testing.expect(d.params.len == 5);
try testing.expectEqual(@as(u16, 48), d.params[0]);
try testing.expect(d.params_sep.isSet(0));
try testing.expectEqual(@as(u16, 2), d.params[1]);
try testing.expect(d.params_sep.isSet(1));
try testing.expectEqual(@as(u16, 240), d.params[2]);
try testing.expect(d.params_sep.isSet(2));
try testing.expectEqual(@as(u16, 143), d.params[3]);
try testing.expect(d.params_sep.isSet(3));
try testing.expectEqual(@as(u16, 104), d.params[4]);
try testing.expect(!d.params_sep.isSet(4));
}
}
@ -608,10 +594,11 @@ test "csi: SGR ESC [4:3m colon" {
const d = a[1].?.csi_dispatch;
try testing.expect(d.final == 'm');
try testing.expect(d.sep == .colon);
try testing.expect(d.params.len == 2);
try testing.expectEqual(@as(u16, 4), d.params[0]);
try testing.expect(d.params_sep.isSet(0));
try testing.expectEqual(@as(u16, 3), d.params[1]);
try testing.expect(!d.params_sep.isSet(1));
}
}
@ -634,14 +621,71 @@ test "csi: SGR with many blank and colon" {
const d = a[1].?.csi_dispatch;
try testing.expect(d.final == 'm');
try testing.expect(d.sep == .colon);
try testing.expect(d.params.len == 6);
try testing.expectEqual(@as(u16, 58), d.params[0]);
try testing.expect(d.params_sep.isSet(0));
try testing.expectEqual(@as(u16, 2), d.params[1]);
try testing.expect(d.params_sep.isSet(1));
try testing.expectEqual(@as(u16, 0), d.params[2]);
try testing.expect(d.params_sep.isSet(2));
try testing.expectEqual(@as(u16, 240), d.params[3]);
try testing.expect(d.params_sep.isSet(3));
try testing.expectEqual(@as(u16, 143), d.params[4]);
try testing.expect(d.params_sep.isSet(4));
try testing.expectEqual(@as(u16, 104), d.params[5]);
try testing.expect(!d.params_sep.isSet(5));
}
}
// This is from a Kakoune actual SGR sequence.
test "csi: SGR mixed colon and semicolon with blank" {
var p = init();
_ = p.next(0x1B);
for ("[;4:3;38;2;175;175;215;58:2::190:80:70") |c| {
const a = p.next(c);
try testing.expect(a[0] == null);
try testing.expect(a[1] == null);
try testing.expect(a[2] == null);
}
{
const a = p.next('m');
try testing.expect(p.state == .ground);
try testing.expect(a[0] == null);
try testing.expect(a[1].? == .csi_dispatch);
try testing.expect(a[2] == null);
const d = a[1].?.csi_dispatch;
try testing.expect(d.final == 'm');
try testing.expectEqual(14, d.params.len);
try testing.expectEqual(@as(u16, 0), d.params[0]);
try testing.expect(!d.params_sep.isSet(0));
try testing.expectEqual(@as(u16, 4), d.params[1]);
try testing.expect(d.params_sep.isSet(1));
try testing.expectEqual(@as(u16, 3), d.params[2]);
try testing.expect(!d.params_sep.isSet(2));
try testing.expectEqual(@as(u16, 38), d.params[3]);
try testing.expect(!d.params_sep.isSet(3));
try testing.expectEqual(@as(u16, 2), d.params[4]);
try testing.expect(!d.params_sep.isSet(4));
try testing.expectEqual(@as(u16, 175), d.params[5]);
try testing.expect(!d.params_sep.isSet(5));
try testing.expectEqual(@as(u16, 175), d.params[6]);
try testing.expect(!d.params_sep.isSet(6));
try testing.expectEqual(@as(u16, 215), d.params[7]);
try testing.expect(!d.params_sep.isSet(7));
try testing.expectEqual(@as(u16, 58), d.params[8]);
try testing.expect(d.params_sep.isSet(8));
try testing.expectEqual(@as(u16, 2), d.params[9]);
try testing.expect(d.params_sep.isSet(9));
try testing.expectEqual(@as(u16, 0), d.params[10]);
try testing.expect(d.params_sep.isSet(10));
try testing.expectEqual(@as(u16, 190), d.params[11]);
try testing.expect(d.params_sep.isSet(11));
try testing.expectEqual(@as(u16, 80), d.params[12]);
try testing.expect(d.params_sep.isSet(12));
try testing.expectEqual(@as(u16, 70), d.params[13]);
try testing.expect(!d.params_sep.isSet(13));
}
}

View File

@ -1,13 +1,17 @@
//! SGR (Select Graphic Rendition) attrinvbute parsing and types.
const std = @import("std");
const assert = std.debug.assert;
const testing = std.testing;
const color = @import("color.zig");
const SepList = @import("Parser.zig").Action.CSI.SepList;
/// Attribute type for SGR
pub const Attribute = union(enum) {
pub const Tag = std.meta.FieldEnum(Attribute);
/// Unset all attributes
unset: void,
unset,
/// Unknown attribute, the raw CSI command parameters are here.
unknown: struct {
@ -19,43 +23,43 @@ pub const Attribute = union(enum) {
},
/// Bold the text.
bold: void,
reset_bold: void,
bold,
reset_bold,
/// Italic text.
italic: void,
reset_italic: void,
italic,
reset_italic,
/// Faint/dim text.
/// Note: reset faint is the same SGR code as reset bold
faint: void,
faint,
/// Underline the text
underline: Underline,
reset_underline: void,
reset_underline,
underline_color: color.RGB,
@"256_underline_color": u8,
reset_underline_color: void,
reset_underline_color,
// Overline the text
overline: void,
reset_overline: void,
overline,
reset_overline,
/// Blink the text
blink: void,
reset_blink: void,
blink,
reset_blink,
/// Invert fg/bg colors.
inverse: void,
reset_inverse: void,
inverse,
reset_inverse,
/// Invisible
invisible: void,
reset_invisible: void,
invisible,
reset_invisible,
/// Strikethrough the text.
strikethrough: void,
reset_strikethrough: void,
strikethrough,
reset_strikethrough,
/// Set foreground color as RGB values.
direct_color_fg: color.RGB,
@ -68,8 +72,8 @@ pub const Attribute = union(enum) {
@"8_fg": color.Name,
/// Reset the fg/bg to their default values.
reset_fg: void,
reset_bg: void,
reset_fg,
reset_bg,
/// Set the background/foreground as a named bright color attribute.
@"8_bright_bg": color.Name,
@ -94,11 +98,9 @@ pub const Attribute = union(enum) {
/// Parser parses the attributes from a list of SGR parameters.
pub const Parser = struct {
params: []const u16,
params_sep: SepList = SepList.initEmpty(),
idx: usize = 0,
/// True if the separator is a colon
colon: bool = false,
/// Next returns the next attribute or null if there are no more attributes.
pub fn next(self: *Parser) ?Attribute {
if (self.idx > self.params.len) return null;
@ -106,220 +108,261 @@ pub const Parser = struct {
// Implicitly means unset
if (self.params.len == 0) {
self.idx += 1;
return Attribute{ .unset = {} };
return .unset;
}
const slice = self.params[self.idx..self.params.len];
const colon = self.params_sep.isSet(self.idx);
self.idx += 1;
// Our last one will have an idx be the last value.
if (slice.len == 0) return null;
// If we have a colon separator then we need to ensure we're
// parsing a value that allows it.
if (colon) switch (slice[0]) {
4, 38, 48, 58 => {},
else => {
// Consume all the colon separated values.
const start = self.idx;
while (self.params_sep.isSet(self.idx)) self.idx += 1;
self.idx += 1;
return .{ .unknown = .{
.full = self.params,
.partial = slice[0 .. self.idx - start + 1],
} };
},
};
switch (slice[0]) {
0 => return Attribute{ .unset = {} },
0 => return .unset,
1 => return Attribute{ .bold = {} },
1 => return .bold,
2 => return Attribute{ .faint = {} },
2 => return .faint,
3 => return Attribute{ .italic = {} },
3 => return .italic,
4 => blk: {
if (self.colon) {
switch (slice.len) {
// 0 is unreachable because we're here and we read
// an element to get here.
0 => unreachable,
4 => underline: {
if (colon) {
assert(slice.len >= 2);
if (self.isColon()) {
self.consumeUnknownColon();
break :underline;
}
// 1 is possible if underline is the last element.
1 => return Attribute{ .underline = .single },
// 2 means we have a specific underline style.
2 => {
self.idx += 1;
switch (slice[1]) {
0 => return Attribute{ .reset_underline = {} },
1 => return Attribute{ .underline = .single },
2 => return Attribute{ .underline = .double },
3 => return Attribute{ .underline = .curly },
4 => return Attribute{ .underline = .dotted },
5 => return Attribute{ .underline = .dashed },
0 => return .reset_underline,
1 => return .{ .underline = .single },
2 => return .{ .underline = .double },
3 => return .{ .underline = .curly },
4 => return .{ .underline = .dotted },
5 => return .{ .underline = .dashed },
// For unknown underline styles, just render
// a single underline.
else => return Attribute{ .underline = .single },
else => return .{ .underline = .single },
}
}
return .{ .underline = .single };
},
// Colon-separated must only be 2.
else => break :blk,
}
}
5 => return .blink,
return Attribute{ .underline = .single };
},
6 => return .blink,
5 => return Attribute{ .blink = {} },
7 => return .inverse,
6 => return Attribute{ .blink = {} },
8 => return .invisible,
7 => return Attribute{ .inverse = {} },
9 => return .strikethrough,
8 => return Attribute{ .invisible = {} },
21 => return .{ .underline = .double },
9 => return Attribute{ .strikethrough = {} },
22 => return .reset_bold,
21 => return Attribute{ .underline = .double },
23 => return .reset_italic,
22 => return Attribute{ .reset_bold = {} },
24 => return .reset_underline,
23 => return Attribute{ .reset_italic = {} },
25 => return .reset_blink,
24 => return Attribute{ .reset_underline = {} },
27 => return .reset_inverse,
25 => return Attribute{ .reset_blink = {} },
28 => return .reset_invisible,
27 => return Attribute{ .reset_inverse = {} },
29 => return .reset_strikethrough,
28 => return Attribute{ .reset_invisible = {} },
29 => return Attribute{ .reset_strikethrough = {} },
30...37 => return Attribute{
30...37 => return .{
.@"8_fg" = @enumFromInt(slice[0] - 30),
},
38 => if (slice.len >= 2) switch (slice[1]) {
// `2` indicates direct-color (r, g, b).
// We need at least 3 more params for this to make sense.
2 => if (slice.len >= 5) {
self.idx += 4;
// When a colon separator is used, there may or may not be
// a color space identifier as the third param, which we
// need to ignore (it has no standardized behavior).
const rgb = if (slice.len == 5 or !self.colon)
slice[2..5]
else rgb: {
self.idx += 1;
break :rgb slice[3..6];
};
2 => if (self.parseDirectColor(
.direct_color_fg,
slice,
colon,
)) |v| return v,
// We use @truncate because the value should be 0 to 255. If
// it isn't, the behavior is undefined so we just... truncate it.
return Attribute{
.direct_color_fg = .{
.r = @truncate(rgb[0]),
.g = @truncate(rgb[1]),
.b = @truncate(rgb[2]),
},
};
},
// `5` indicates indexed color.
5 => if (slice.len >= 3) {
self.idx += 2;
return Attribute{
return .{
.@"256_fg" = @truncate(slice[2]),
};
},
else => {},
},
39 => return Attribute{ .reset_fg = {} },
39 => return .reset_fg,
40...47 => return Attribute{
40...47 => return .{
.@"8_bg" = @enumFromInt(slice[0] - 40),
},
48 => if (slice.len >= 2) switch (slice[1]) {
// `2` indicates direct-color (r, g, b).
// We need at least 3 more params for this to make sense.
2 => if (slice.len >= 5) {
self.idx += 4;
// When a colon separator is used, there may or may not be
// a color space identifier as the third param, which we
// need to ignore (it has no standardized behavior).
const rgb = if (slice.len == 5 or !self.colon)
slice[2..5]
else rgb: {
self.idx += 1;
break :rgb slice[3..6];
};
2 => if (self.parseDirectColor(
.direct_color_bg,
slice,
colon,
)) |v| return v,
// We use @truncate because the value should be 0 to 255. If
// it isn't, the behavior is undefined so we just... truncate it.
return Attribute{
.direct_color_bg = .{
.r = @truncate(rgb[0]),
.g = @truncate(rgb[1]),
.b = @truncate(rgb[2]),
},
};
},
// `5` indicates indexed color.
5 => if (slice.len >= 3) {
self.idx += 2;
return Attribute{
return .{
.@"256_bg" = @truncate(slice[2]),
};
},
else => {},
},
49 => return Attribute{ .reset_bg = {} },
49 => return .reset_bg,
53 => return Attribute{ .overline = {} },
55 => return Attribute{ .reset_overline = {} },
53 => return .overline,
55 => return .reset_overline,
58 => if (slice.len >= 2) switch (slice[1]) {
// `2` indicates direct-color (r, g, b).
// We need at least 3 more params for this to make sense.
2 => if (slice.len >= 5) {
self.idx += 4;
// When a colon separator is used, there may or may not be
// a color space identifier as the third param, which we
// need to ignore (it has no standardized behavior).
const rgb = if (slice.len == 5 or !self.colon)
slice[2..5]
else rgb: {
self.idx += 1;
break :rgb slice[3..6];
};
2 => if (self.parseDirectColor(
.underline_color,
slice,
colon,
)) |v| return v,
// We use @truncate because the value should be 0 to 255. If
// it isn't, the behavior is undefined so we just... truncate it.
return Attribute{
.underline_color = .{
.r = @truncate(rgb[0]),
.g = @truncate(rgb[1]),
.b = @truncate(rgb[2]),
},
};
},
// `5` indicates indexed color.
5 => if (slice.len >= 3) {
self.idx += 2;
return Attribute{
return .{
.@"256_underline_color" = @truncate(slice[2]),
};
},
else => {},
},
59 => return Attribute{ .reset_underline_color = {} },
59 => return .reset_underline_color,
90...97 => return Attribute{
90...97 => return .{
// 82 instead of 90 to offset to "bright" colors
.@"8_bright_fg" = @enumFromInt(slice[0] - 82),
},
100...107 => return Attribute{
100...107 => return .{
.@"8_bright_bg" = @enumFromInt(slice[0] - 92),
},
else => {},
}
return Attribute{ .unknown = .{ .full = self.params, .partial = slice } };
return .{ .unknown = .{ .full = self.params, .partial = slice } };
}
fn parseDirectColor(
self: *Parser,
comptime tag: Attribute.Tag,
slice: []const u16,
colon: bool,
) ?Attribute {
// Any direct color style must have at least 5 values.
if (slice.len < 5) return null;
// Only used for direct color sets (38, 48, 58) and subparam 2.
assert(slice[1] == 2);
// Note: We use @truncate because the value should be 0 to 255. If
// it isn't, the behavior is undefined so we just... truncate it.
// If we don't have a colon, then we expect exactly 3 semicolon
// separated values.
if (!colon) {
self.idx += 4;
return @unionInit(Attribute, @tagName(tag), .{
.r = @truncate(slice[2]),
.g = @truncate(slice[3]),
.b = @truncate(slice[4]),
});
}
// We have a colon, we might have either 5 or 6 values depending
// on if the colorspace is present.
const count = self.countColon();
switch (count) {
3 => {
self.idx += 4;
return @unionInit(Attribute, @tagName(tag), .{
.r = @truncate(slice[2]),
.g = @truncate(slice[3]),
.b = @truncate(slice[4]),
});
},
4 => {
self.idx += 5;
return @unionInit(Attribute, @tagName(tag), .{
.r = @truncate(slice[3]),
.g = @truncate(slice[4]),
.b = @truncate(slice[5]),
});
},
else => {
self.consumeUnknownColon();
return null;
},
}
}
/// Returns true if the present position has a colon separator.
/// This always returns false for the last value since it has no
/// separator.
fn isColon(self: *Parser) bool {
// The `- 1` here is because the last value has no separator.
if (self.idx >= self.params.len - 1) return false;
return self.params_sep.isSet(self.idx);
}
fn countColon(self: *Parser) usize {
var count: usize = 0;
var idx = self.idx;
while (idx < self.params.len - 1 and self.params_sep.isSet(idx)) : (idx += 1) {
count += 1;
}
return count;
}
/// Consumes all the remaining parameters separated by a colon and
/// returns an unknown attribute.
fn consumeUnknownColon(self: *Parser) void {
const count = self.countColon();
self.idx += count + 1;
}
};
@ -329,7 +372,7 @@ fn testParse(params: []const u16) Attribute {
}
fn testParseColon(params: []const u16) Attribute {
var p: Parser = .{ .params = params, .colon = true };
var p: Parser = .{ .params = params, .params_sep = SepList.initFull() };
return p.next().?;
}
@ -366,6 +409,35 @@ test "sgr: Parser multiple" {
try testing.expect(p.next() == null);
}
test "sgr: unsupported with colon" {
var p: Parser = .{
.params = &[_]u16{ 0, 4, 1 },
.params_sep = sep: {
var list = SepList.initEmpty();
list.set(0);
break :sep list;
},
};
try testing.expect(p.next().? == .unknown);
try testing.expect(p.next().? == .bold);
try testing.expect(p.next() == null);
}
test "sgr: unsupported with multiple colon" {
var p: Parser = .{
.params = &[_]u16{ 0, 4, 2, 1 },
.params_sep = sep: {
var list = SepList.initEmpty();
list.set(0);
list.set(1);
break :sep list;
},
};
try testing.expect(p.next().? == .unknown);
try testing.expect(p.next().? == .bold);
try testing.expect(p.next() == null);
}
test "sgr: bold" {
{
const v = testParse(&[_]u16{1});
@ -439,6 +511,37 @@ test "sgr: underline styles" {
}
}
test "sgr: underline style with more" {
var p: Parser = .{
.params = &[_]u16{ 4, 2, 1 },
.params_sep = sep: {
var list = SepList.initEmpty();
list.set(0);
break :sep list;
},
};
try testing.expect(p.next().? == .underline);
try testing.expect(p.next().? == .bold);
try testing.expect(p.next() == null);
}
test "sgr: underline style with too many colons" {
var p: Parser = .{
.params = &[_]u16{ 4, 2, 3, 1 },
.params_sep = sep: {
var list = SepList.initEmpty();
list.set(0);
list.set(1);
break :sep list;
},
};
try testing.expect(p.next().? == .unknown);
try testing.expect(p.next().? == .bold);
try testing.expect(p.next() == null);
}
test "sgr: blink" {
{
const v = testParse(&[_]u16{5});
@ -592,13 +695,13 @@ test "sgr: underline, bg, and fg" {
test "sgr: direct color fg missing color" {
// This used to crash
var p: Parser = .{ .params = &[_]u16{ 38, 5 }, .colon = false };
var p: Parser = .{ .params = &[_]u16{ 38, 5 } };
while (p.next()) |_| {}
}
test "sgr: direct color bg missing color" {
// This used to crash
var p: Parser = .{ .params = &[_]u16{ 48, 5 }, .colon = false };
var p: Parser = .{ .params = &[_]u16{ 48, 5 } };
while (p.next()) |_| {}
}
@ -608,7 +711,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
// Colon version should skip the optional color space identifier
{
// 3 8 : 2 : Pi : Pr : Pg : Pb
const v = testParseColon(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 });
const v = testParseColon(&[_]u16{ 38, 2, 0, 1, 2, 3 });
try testing.expect(v == .direct_color_fg);
try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r);
try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g);
@ -616,7 +719,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
}
{
// 4 8 : 2 : Pi : Pr : Pg : Pb
const v = testParseColon(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 });
const v = testParseColon(&[_]u16{ 48, 2, 0, 1, 2, 3 });
try testing.expect(v == .direct_color_bg);
try testing.expectEqual(@as(u8, 1), v.direct_color_bg.r);
try testing.expectEqual(@as(u8, 2), v.direct_color_bg.g);
@ -624,7 +727,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
}
{
// 5 8 : 2 : Pi : Pr : Pg : Pb
const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 });
const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3 });
try testing.expect(v == .underline_color);
try testing.expectEqual(@as(u8, 1), v.underline_color.r);
try testing.expectEqual(@as(u8, 2), v.underline_color.g);
@ -634,7 +737,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
// Semicolon version should not parse optional color space identifier
{
// 3 8 ; 2 ; Pr ; Pg ; Pb
const v = testParse(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 });
const v = testParse(&[_]u16{ 38, 2, 0, 1, 2, 3 });
try testing.expect(v == .direct_color_fg);
try testing.expectEqual(@as(u8, 0), v.direct_color_fg.r);
try testing.expectEqual(@as(u8, 1), v.direct_color_fg.g);
@ -642,7 +745,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
}
{
// 4 8 ; 2 ; Pr ; Pg ; Pb
const v = testParse(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 });
const v = testParse(&[_]u16{ 48, 2, 0, 1, 2, 3 });
try testing.expect(v == .direct_color_bg);
try testing.expectEqual(@as(u8, 0), v.direct_color_bg.r);
try testing.expectEqual(@as(u8, 1), v.direct_color_bg.g);
@ -650,10 +753,114 @@ test "sgr: direct fg/bg/underline ignore optional color space" {
}
{
// 5 8 ; 2 ; Pr ; Pg ; Pb
const v = testParse(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 });
const v = testParse(&[_]u16{ 58, 2, 0, 1, 2, 3 });
try testing.expect(v == .underline_color);
try testing.expectEqual(@as(u8, 0), v.underline_color.r);
try testing.expectEqual(@as(u8, 1), v.underline_color.g);
try testing.expectEqual(@as(u8, 2), v.underline_color.b);
}
}
test "sgr: direct fg colon with too many colons" {
var p: Parser = .{
.params = &[_]u16{ 38, 2, 0, 1, 2, 3, 4, 1 },
.params_sep = sep: {
var list = SepList.initEmpty();
for (0..6) |idx| list.set(idx);
break :sep list;
},
};
try testing.expect(p.next().? == .unknown);
try testing.expect(p.next().? == .bold);
try testing.expect(p.next() == null);
}
test "sgr: direct fg colon with colorspace and extra param" {
var p: Parser = .{
.params = &[_]u16{ 38, 2, 0, 1, 2, 3, 1 },
.params_sep = sep: {
var list = SepList.initEmpty();
for (0..5) |idx| list.set(idx);
break :sep list;
},
};
{
const v = p.next().?;
std.log.warn("WHAT={}", .{v});
try testing.expect(v == .direct_color_fg);
try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r);
try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g);
try testing.expectEqual(@as(u8, 3), v.direct_color_fg.b);
}
try testing.expect(p.next().? == .bold);
try testing.expect(p.next() == null);
}
test "sgr: direct fg colon no colorspace and extra param" {
var p: Parser = .{
.params = &[_]u16{ 38, 2, 1, 2, 3, 1 },
.params_sep = sep: {
var list = SepList.initEmpty();
for (0..4) |idx| list.set(idx);
break :sep list;
},
};
{
const v = p.next().?;
try testing.expect(v == .direct_color_fg);
try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r);
try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g);
try testing.expectEqual(@as(u8, 3), v.direct_color_fg.b);
}
try testing.expect(p.next().? == .bold);
try testing.expect(p.next() == null);
}
// Kakoune sent this complex SGR sequence that caused invalid behavior.
test "sgr: kakoune input" {
// This used to crash
var p: Parser = .{
.params = &[_]u16{ 0, 4, 3, 38, 2, 175, 175, 215, 58, 2, 0, 190, 80, 70 },
.params_sep = sep: {
var list = SepList.initEmpty();
list.set(1);
list.set(8);
list.set(9);
list.set(10);
list.set(11);
list.set(12);
break :sep list;
},
};
{
const v = p.next().?;
try testing.expect(v == .unset);
}
{
const v = p.next().?;
try testing.expect(v == .underline);
try testing.expectEqual(Attribute.Underline.curly, v.underline);
}
{
const v = p.next().?;
try testing.expect(v == .direct_color_fg);
try testing.expectEqual(@as(u8, 175), v.direct_color_fg.r);
try testing.expectEqual(@as(u8, 175), v.direct_color_fg.g);
try testing.expectEqual(@as(u8, 215), v.direct_color_fg.b);
}
{
const v = p.next().?;
try testing.expect(v == .underline_color);
try testing.expectEqual(@as(u8, 190), v.underline_color.r);
try testing.expectEqual(@as(u8, 80), v.underline_color.g);
try testing.expectEqual(@as(u8, 70), v.underline_color.b);
}
//try testing.expect(p.next() == null);
}

View File

@ -253,15 +253,11 @@ pub fn Stream(comptime Handler: type) type {
// A parameter separator:
':', ';' => if (self.parser.params_idx < 16) {
self.parser.params[self.parser.params_idx] = self.parser.param_acc;
if (c == ':') self.parser.params_sep.set(self.parser.params_idx);
self.parser.params_idx += 1;
self.parser.param_acc = 0;
self.parser.param_acc_idx = 0;
// Keep track of separator state.
const sep: Parser.ParamSepState = @enumFromInt(c);
if (self.parser.params_idx == 1) self.parser.params_sep = sep;
if (self.parser.params_sep != sep) self.parser.params_sep = .mixed;
},
// Explicitly ignored:
0x7F => {},
@ -937,7 +933,10 @@ pub fn Stream(comptime Handler: type) type {
'm' => switch (input.intermediates.len) {
0 => if (@hasDecl(T, "setAttribute")) {
// log.info("parse SGR params={any}", .{action.params});
var p: sgr.Parser = .{ .params = input.params, .colon = input.sep == .colon };
var p: sgr.Parser = .{
.params = input.params,
.params_sep = input.params_sep,
};
while (p.next()) |attr| {
// log.info("SGR attribute: {}", .{attr});
try self.handler.setAttribute(attr);