diff --git a/src/cli/CommaSplitter.zig b/src/cli/CommaSplitter.zig new file mode 100644 index 000000000..3168c1ffa --- /dev/null +++ b/src/cli/CommaSplitter.zig @@ -0,0 +1,424 @@ +//! Iterator to split a string into fields by commas, taking into account +//! quotes and escapes. +//! +//! Supports the same escapes as in Zig literal strings. +//! +//! Quotes must begin and end with a double quote (`"`). It is an error to not +//! end a quote that was begun. To include a double quote inside a quote (or to +//! not have a double quote start a quoted section) escape it with a backslash. +//! +//! Single quotes (`'`) are not special, they do not begin a quoted block. +//! +//! Zig multiline string literals are NOT supported. +//! +//! Quotes and escapes are not stripped or decoded, that must be handled as a +//! separate step! +const CommaSplitter = @This(); + +pub const Error = error{ + UnclosedQuote, + UnfinishedEscape, + IllegalEscape, +}; + +/// the string that we are splitting +str: []const u8, +/// how much of the string has been consumed so far +index: usize, + +/// initialize a splitter with the given string +pub fn init(str: []const u8) CommaSplitter { + return .{ + .str = str, + .index = 0, + }; +} + +/// return the next field, null if no more fields +pub fn next(self: *CommaSplitter) Error!?[]const u8 { + if (self.index >= self.str.len) return null; + + // where the current field starts + const start = self.index; + // state of state machine + const State = enum { + normal, + quoted, + escape, + hexescape, + unicodeescape, + }; + // keep track of the state to return to when done processing an escape + // sequence. + var last: State = .normal; + // used to count number of digits seen in a hex escape + var hexescape_digits: usize = 0; + // sub-state of parsing hex escapes + var unicodeescape_state: enum { + start, + digits, + } = .start; + // number of digits in a unicode escape seen so far + var unicodeescape_digits: usize = 0; + // accumulator for value of unicode escape + var unicodeescape_value: usize = 0; + + loop: switch (State.normal) { + .normal => { + if (self.index >= self.str.len) return self.str[start..]; + switch (self.str[self.index]) { + ',' => { + self.index += 1; + return self.str[start .. self.index - 1]; + }, + '"' => { + self.index += 1; + continue :loop .quoted; + }, + '\\' => { + self.index += 1; + last = .normal; + continue :loop .escape; + }, + else => { + self.index += 1; + continue :loop .normal; + }, + } + }, + .quoted => { + if (self.index >= self.str.len) return error.UnclosedQuote; + switch (self.str[self.index]) { + '"' => { + self.index += 1; + continue :loop .normal; + }, + '\\' => { + self.index += 1; + last = .quoted; + continue :loop .escape; + }, + else => { + self.index += 1; + continue :loop .quoted; + }, + } + }, + .escape => { + if (self.index >= self.str.len) return error.UnfinishedEscape; + switch (self.str[self.index]) { + 'n', 'r', 't', '\\', '\'', '"' => { + self.index += 1; + continue :loop last; + }, + 'x' => { + self.index += 1; + hexescape_digits = 0; + continue :loop .hexescape; + }, + 'u' => { + self.index += 1; + unicodeescape_state = .start; + unicodeescape_digits = 0; + unicodeescape_value = 0; + continue :loop .unicodeescape; + }, + else => return error.IllegalEscape, + } + }, + .hexescape => { + if (self.index >= self.str.len) return error.UnfinishedEscape; + switch (self.str[self.index]) { + '0'...'9', 'a'...'f', 'A'...'F' => { + self.index += 1; + hexescape_digits += 1; + if (hexescape_digits == 2) continue :loop last; + continue :loop .hexescape; + }, + else => return error.IllegalEscape, + } + }, + .unicodeescape => { + if (self.index >= self.str.len) return error.UnfinishedEscape; + switch (unicodeescape_state) { + .start => { + switch (self.str[self.index]) { + '{' => { + self.index += 1; + unicodeescape_value = 0; + unicodeescape_state = .digits; + continue :loop .unicodeescape; + }, + else => return error.IllegalEscape, + } + }, + .digits => { + switch (self.str[self.index]) { + '}' => { + self.index += 1; + if (unicodeescape_digits == 0) return error.IllegalEscape; + continue :loop last; + }, + '0'...'9' => |d| { + self.index += 1; + unicodeescape_digits += 1; + unicodeescape_value <<= 4; + unicodeescape_value += d - '0'; + }, + 'a'...'f' => |d| { + self.index += 1; + unicodeescape_digits += 1; + unicodeescape_value <<= 4; + unicodeescape_value += d - 'a'; + }, + 'A'...'F' => |d| { + self.index += 1; + unicodeescape_digits += 1; + unicodeescape_value <<= 4; + unicodeescape_value += d - 'A'; + }, + else => return error.IllegalEscape, + } + if (unicodeescape_value > 0x10ffff) return error.IllegalEscape; + continue :loop .unicodeescape; + }, + } + }, + } +} + +/// Return any remaining string data, whether it has a comma or not. +pub fn rest(self: *CommaSplitter) ?[]const u8 { + if (self.index >= self.str.len) return null; + defer self.index = self.str.len; + return self.str[self.index..]; +} + +test "splitter 1" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("a,b,c"); + try testing.expectEqualStrings("a", (try s.next()).?); + try testing.expectEqualStrings("b", (try s.next()).?); + try testing.expectEqualStrings("c", (try s.next()).?); + try testing.expect(null == try s.next()); +} + +test "splitter 2" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init(""); + try testing.expect(null == try s.next()); +} + +test "splitter 3" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("a"); + try testing.expectEqualStrings("a", (try s.next()).?); + try testing.expect(null == try s.next()); +} + +test "splitter 4" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("\\x5a"); + try testing.expectEqualStrings("\\x5a", (try s.next()).?); + try testing.expect(null == try s.next()); +} + +test "splitter 5" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("'a',b"); + try testing.expectEqualStrings("'a'", (try s.next()).?); + try testing.expectEqualStrings("b", (try s.next()).?); + try testing.expect(null == try s.next()); +} + +test "splitter 6" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("'a,b',c"); + try testing.expectEqualStrings("'a", (try s.next()).?); + try testing.expectEqualStrings("b'", (try s.next()).?); + try testing.expectEqualStrings("c", (try s.next()).?); + try testing.expect(null == try s.next()); +} + +test "splitter 7" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("\"a,b\",c"); + try testing.expectEqualStrings("\"a,b\"", (try s.next()).?); + try testing.expectEqualStrings("c", (try s.next()).?); + try testing.expect(null == try s.next()); +} + +test "splitter 8" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init(" a , b "); + try testing.expectEqualStrings(" a ", (try s.next()).?); + try testing.expectEqualStrings(" b ", (try s.next()).?); + try testing.expect(null == try s.next()); +} + +test "splitter 9" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("\\x"); + try testing.expectError(error.UnfinishedEscape, s.next()); +} + +test "splitter 10" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("\\x5"); + try testing.expectError(error.UnfinishedEscape, s.next()); +} + +test "splitter 11" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("\\u"); + try testing.expectError(error.UnfinishedEscape, s.next()); +} + +test "splitter 12" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("\\u{"); + try testing.expectError(error.UnfinishedEscape, s.next()); +} + +test "splitter 13" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("\\u{}"); + try testing.expectError(error.IllegalEscape, s.next()); +} + +test "splitter 14" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("\\u{h1}"); + try testing.expectError(error.IllegalEscape, s.next()); +} + +test "splitter 15" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("\\u{10ffff}"); + try testing.expectEqualStrings("\\u{10ffff}", (try s.next()).?); + try testing.expect(null == try s.next()); +} + +test "splitter 16" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("\\u{110000}"); + try testing.expectError(error.IllegalEscape, s.next()); +} + +test "splitter 17" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("\\d"); + try testing.expectError(error.IllegalEscape, s.next()); +} + +test "splitter 18" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("\\n\\r\\t\\\"\\'\\\\"); + try testing.expectEqualStrings("\\n\\r\\t\\\"\\'\\\\", (try s.next()).?); + try testing.expect(null == try s.next()); +} + +test "splitter 19" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("\"abc'def'ghi\""); + try testing.expectEqualStrings("\"abc'def'ghi\"", (try s.next()).?); + try testing.expect(null == try s.next()); +} + +test "splitter 20" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("\",\",abc"); + try testing.expectEqualStrings("\",\"", (try s.next()).?); + try testing.expectEqualStrings("abc", (try s.next()).?); + try testing.expect(null == try s.next()); +} + +test "splitter 21" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("'a','b', 'c'"); + try testing.expectEqualStrings("'a'", (try s.next()).?); + try testing.expectEqualStrings("'b'", (try s.next()).?); + try testing.expectEqualStrings(" 'c'", (try s.next()).?); + try testing.expect(null == try s.next()); +} + +test "splitter 22" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("abc\"def"); + try testing.expectError(error.UnclosedQuote, s.next()); +} + +test "splitter 23" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("title:\"Focus Split: Up\",description:\"Focus the split above, if it exists.\",action:goto_split:up"); + try testing.expectEqualStrings("title:\"Focus Split: Up\"", (try s.next()).?); + try testing.expectEqualStrings("description:\"Focus the split above, if it exists.\"", (try s.next()).?); + try testing.expectEqualStrings("action:goto_split:up", (try s.next()).?); + try testing.expect(null == try s.next()); +} + +test "splitter 24" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("a,b,c,def"); + try testing.expectEqualStrings("a", (try s.next()).?); + try testing.expectEqualStrings("b", (try s.next()).?); + try testing.expectEqualStrings("c,def", s.rest().?); + try testing.expect(null == try s.next()); +} + +test "splitter 25" { + const std = @import("std"); + const testing = std.testing; + + var s: CommaSplitter = .init("a,\\u{10,df}"); + try testing.expectEqualStrings("a", (try s.next()).?); + try testing.expectError(error.IllegalEscape, s.next()); +} diff --git a/src/cli/args.zig b/src/cli/args.zig index 4db0a29a2..2d2d199be 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -7,6 +7,7 @@ const diags = @import("diagnostics.zig"); const internal_os = @import("../os/main.zig"); const Diagnostic = diags.Diagnostic; const DiagnosticList = diags.DiagnosticList; +const CommaSplitter = @import("CommaSplitter.zig"); const log = std.log.scoped(.cli); @@ -527,24 +528,31 @@ pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T { const FieldSet = std.StaticBitSet(info.fields.len); var fields_set: FieldSet = .initEmpty(); - // We split each value by "," - var iter = std.mem.splitSequence(u8, v, ","); - loop: while (iter.next()) |entry| { + // We split each value by "," allowing for quoting and escaping. + var iter: CommaSplitter = .init(v); + loop: while (try iter.next()) |entry| { // Find the key/value, trimming whitespace. The value may be quoted // which we strip the quotes from. const idx = mem.indexOf(u8, entry, ":") orelse return error.InvalidValue; const key = std.mem.trim(u8, entry[0..idx], whitespace); + + // used if we need to decode a double-quoted string. + var buf: std.ArrayListUnmanaged(u8) = .empty; + defer buf.deinit(alloc); + const value = value: { - var value = std.mem.trim(u8, entry[idx + 1 ..], whitespace); + const value = std.mem.trim(u8, entry[idx + 1 ..], whitespace); // Detect a quoted string. if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') { - // Trim quotes since our CLI args processor expects - // quotes to already be gone. - value = value[1 .. value.len - 1]; + // Decode a double-quoted string as a Zig string literal. + const writer = buf.writer(alloc); + const parsed = try std.zig.string_literal.parseWrite(writer, value); + if (parsed == .failure) return error.InvalidValue; + break :value buf.items; } break :value value; diff --git a/src/config/Config.zig b/src/config/Config.zig index a3a8388a5..66e63fd3f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2363,9 +2363,21 @@ keybind: Keybinds = .{}, /// (`:`), and then the specified value. The syntax for actions is identical /// to the one for keybind actions. Whitespace in between fields is ignored. /// +/// If you need to embed commas or any other special characters in the values, +/// enclose the value in double quotes and it will be interpreted as a Zig +/// string literal. This is also useful for including whitespace at the +/// beginning or the end of a value. See the +/// [Zig documentation](https://ziglang.org/documentation/master/#Escape-Sequences) +/// for more information on string literals. Note that multiline string literals +/// are not supported. +/// +/// Double quotes can not be used around the field names. +/// /// ```ini /// command-palette-entry = title:Reset Font Style, action:csi:0m /// command-palette-entry = title:Crash on Main Thread,description:Causes a crash on the main (UI) thread.,action:crash:main +/// command-palette-entry = title:Focus Split: Right,description:"Focus the split to the right, if it exists.",action:goto_split:right +/// command-palette-entry = title:"Ghostty",description:"Add a little Ghostty to your terminal.",action:"text:\xf0\x9f\x91\xbb" /// ``` /// /// By default, the command palette is preloaded with most actions that might @@ -7029,18 +7041,24 @@ pub const RepeatableCommand = struct { return; } - var buf: [4096]u8 = undefined; for (self.value.items) |item| { - const str = if (item.description.len > 0) std.fmt.bufPrint( - &buf, - "title:{s},description:{s},action:{}", - .{ item.title, item.description, item.action }, - ) else std.fmt.bufPrint( - &buf, - "title:{s},action:{}", - .{ item.title, item.action }, - ); - try formatter.formatEntry([]const u8, str catch return error.OutOfMemory); + var buf: [4096]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + var writer = fbs.writer(); + + writer.writeAll("title:\"") catch return error.OutOfMemory; + std.zig.stringEscape(item.title, "", .{}, writer) catch return error.OutOfMemory; + writer.writeAll("\"") catch return error.OutOfMemory; + + if (item.description.len > 0) { + writer.writeAll(",description:\"") catch return error.OutOfMemory; + std.zig.stringEscape(item.description, "", .{}, writer) catch return error.OutOfMemory; + writer.writeAll("\"") catch return error.OutOfMemory; + } + + writer.print(",action:\"{}\"", .{item.action}) catch return error.OutOfMemory; + + try formatter.formatEntry([]const u8, fbs.getWritten()); } } @@ -7106,7 +7124,7 @@ pub const RepeatableCommand = struct { var list: RepeatableCommand = .{}; try list.parseCLI(alloc, "title:Bobr, action:text:Bober"); try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); - try std.testing.expectEqualSlices(u8, "a = title:Bobr,action:text:Bober\n", buf.items); + try std.testing.expectEqualSlices(u8, "a = title:\"Bobr\",action:\"text:Bober\"\n", buf.items); } test "RepeatableCommand formatConfig multiple items" { @@ -7122,7 +7140,40 @@ pub const RepeatableCommand = struct { try list.parseCLI(alloc, "title:Bobr, action:text:kurwa"); try list.parseCLI(alloc, "title:Ja, description: pierdole, action:text:jakie bydle"); try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); - try std.testing.expectEqualSlices(u8, "a = title:Bobr,action:text:kurwa\na = title:Ja,description:pierdole,action:text:jakie bydle\n", buf.items); + try std.testing.expectEqualSlices(u8, "a = title:\"Bobr\",action:\"text:kurwa\"\na = title:\"Ja\",description:\"pierdole\",action:\"text:jakie bydle\"\n", buf.items); + } + + test "RepeatableCommand parseCLI commas" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + { + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:\"Bo,br\",action:\"text:kur,wa\""); + try testing.expectEqual(@as(usize, 1), list.value.items.len); + + const item = list.value.items[0]; + try testing.expectEqualStrings("Bo,br", item.title); + try testing.expectEqualStrings("", item.description); + try testing.expect(item.action == .text); + try testing.expectEqualStrings("kur,wa", item.action.text); + } + { + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:\"Bo,br\",description:\"abc,def\",action:text:kurwa"); + try testing.expectEqual(@as(usize, 1), list.value.items.len); + + const item = list.value.items[0]; + try testing.expectEqualStrings("Bo,br", item.title); + try testing.expectEqualStrings("abc,def", item.description); + try testing.expect(item.action == .text); + try testing.expectEqualStrings("kurwa", item.action.text); + } } }; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 54e7754f2..016f6a947 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1213,7 +1213,7 @@ pub const Action = union(enum) { const value_info = @typeInfo(Value); switch (Value) { void => {}, - []const u8 => try writer.print("{s}", .{value}), + []const u8 => try std.zig.stringEscape(value, "", .{}, writer), else => switch (value_info) { .@"enum" => try writer.print("{s}", .{@tagName(value)}), .float => try writer.print("{d}", .{value}), @@ -3223,3 +3223,18 @@ test "parse: set_font_size" { try testing.expectEqual(13.5, binding.action.set_font_size); } } + +test "action: format" { + const testing = std.testing; + const alloc = testing.allocator; + + const a: Action = .{ .text = "👻" }; + + var buf: std.ArrayListUnmanaged(u8) = .empty; + defer buf.deinit(alloc); + + const writer = buf.writer(alloc); + try a.format("", .{}, writer); + + try testing.expectEqualStrings("text:\\xf0\\x9f\\x91\\xbb", buf.items); +} diff --git a/src/input/command.zig b/src/input/command.zig index 63feb2edf..bf5061c12 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -472,13 +472,18 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Quit the application.", }}, + .text => comptime &.{.{ + .action = .{ .text = "👻" }, + .title = "Ghostty", + .description = "Put a little Ghostty in your terminal.", + }}, + // No commands because they're parameterized and there // aren't obvious values users would use. It is possible that // these may have commands in the future if there are very // common values that users tend to use. .csi, .esc, - .text, .cursor_key, .set_font_size, .scroll_page_fractional,