windows: handle backslash paths in config value parsing

CommaSplitter treats backslash as an escape character, which breaks
Windows paths like C:\Users\foo since \U is not a valid escape. On
Windows, treat backslash as a literal character outside of quoted
strings. Inside quotes, escape sequences still work as before.

The platform behavior is controlled by a single comptime constant
(escape_outside_quotes) so the logic lives in one place. Escape-specific
tests are skipped on Windows with SkipZigTest, and Windows-specific
tests are added separately.

Also fix Theme.parseCLI to not mistake the colon in a Windows drive
letter (C:\...) for a light/dark theme pair separator.

Note: other places in the config parsing also use colon as a delimiter
without accounting for Windows drive letters (command.zig prefix
parsing, keybind parsing). Those are tracked separately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
pull/11782/head
Alessandro De Blasis 2026-03-23 19:30:14 +01:00
parent fa10237fb0
commit 909e733120
2 changed files with 69 additions and 3 deletions

View File

@ -13,8 +13,18 @@
//!
//! Quotes and escapes are not stripped or decoded, that must be handled as a
//! separate step!
//!
//! On Windows, backslash is only treated as an escape character inside quoted
//! strings. Outside quotes, backslash is a literal character (path separator).
const CommaSplitter = @This();
const builtin = @import("builtin");
/// Whether backslash acts as an escape character outside quoted strings.
/// On Windows, backslash is the path separator so it is always literal
/// outside quotes.
const escape_outside_quotes = builtin.os.tag != .windows;
pub const Error = error{
UnclosedQuote,
UnfinishedEscape,
@ -77,8 +87,11 @@ pub fn next(self: *CommaSplitter) Error!?[]const u8 {
},
'\\' => {
self.index += 1;
last = .normal;
continue :loop .escape;
if (comptime escape_outside_quotes) {
last = .normal;
continue :loop .escape;
}
continue :loop .normal;
},
else => {
self.index += 1;
@ -273,6 +286,7 @@ test "splitter 8" {
}
test "splitter 9" {
if (comptime !escape_outside_quotes) return error.SkipZigTest;
const std = @import("std");
const testing = std.testing;
@ -281,6 +295,7 @@ test "splitter 9" {
}
test "splitter 10" {
if (comptime !escape_outside_quotes) return error.SkipZigTest;
const std = @import("std");
const testing = std.testing;
@ -289,6 +304,7 @@ test "splitter 10" {
}
test "splitter 11" {
if (comptime !escape_outside_quotes) return error.SkipZigTest;
const std = @import("std");
const testing = std.testing;
@ -297,6 +313,7 @@ test "splitter 11" {
}
test "splitter 12" {
if (comptime !escape_outside_quotes) return error.SkipZigTest;
const std = @import("std");
const testing = std.testing;
@ -305,6 +322,7 @@ test "splitter 12" {
}
test "splitter 13" {
if (comptime !escape_outside_quotes) return error.SkipZigTest;
const std = @import("std");
const testing = std.testing;
@ -313,6 +331,7 @@ test "splitter 13" {
}
test "splitter 14" {
if (comptime !escape_outside_quotes) return error.SkipZigTest;
const std = @import("std");
const testing = std.testing;
@ -330,6 +349,7 @@ test "splitter 15" {
}
test "splitter 16" {
if (comptime !escape_outside_quotes) return error.SkipZigTest;
const std = @import("std");
const testing = std.testing;
@ -338,6 +358,7 @@ test "splitter 16" {
}
test "splitter 17" {
if (comptime !escape_outside_quotes) return error.SkipZigTest;
const std = @import("std");
const testing = std.testing;
@ -346,6 +367,7 @@ test "splitter 17" {
}
test "splitter 18" {
if (comptime !escape_outside_quotes) return error.SkipZigTest;
const std = @import("std");
const testing = std.testing;
@ -415,6 +437,7 @@ test "splitter 24" {
}
test "splitter 25" {
if (comptime !escape_outside_quotes) return error.SkipZigTest;
const std = @import("std");
const testing = std.testing;
@ -422,3 +445,39 @@ test "splitter 25" {
try testing.expectEqualStrings("a", (try s.next()).?);
try testing.expectError(error.IllegalEscape, s.next());
}
// Windows-specific tests: backslash is literal outside quotes.
test "splitter: windows paths" {
if (comptime escape_outside_quotes) return error.SkipZigTest;
const std = @import("std");
const testing = std.testing;
var s: CommaSplitter = .init("light:C:\\Users\\foo\\theme,dark:C:\\Users\\bar\\theme");
try testing.expectEqualStrings("light:C:\\Users\\foo\\theme", (try s.next()).?);
try testing.expectEqualStrings("dark:C:\\Users\\bar\\theme", (try s.next()).?);
try testing.expect(null == try s.next());
}
test "splitter: backslash literal outside quotes on windows" {
if (comptime escape_outside_quotes) return error.SkipZigTest;
const std = @import("std");
const testing = std.testing;
// Backslash followed by characters that would be escapes on Unix
// are treated as literal on Windows outside quotes.
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: backslash still escapes inside quotes on windows" {
if (comptime escape_outside_quotes) return error.SkipZigTest;
const std = @import("std");
const testing = std.testing;
// Inside quotes, backslash escapes work on all platforms.
var s: CommaSplitter = .init("\"hello\\nworld\"");
try testing.expectEqualStrings("\"hello\\nworld\"", (try s.next()).?);
try testing.expect(null == try s.next());
}

View File

@ -9810,9 +9810,16 @@ pub const Theme = struct {
// we're parsing a light/dark mode theme pair. Note that "=" isn't
// actually valid for setting a light/dark mode pair but I anticipate
// it'll be a common typo.
//
// On Windows, a colon at index 1 is a drive letter (e.g. C:\...)
// and should not trigger light/dark pair parsing.
const has_colon = if (comptime builtin.os.tag == .windows)
if (std.mem.indexOf(u8, input, ":")) |idx| idx != 1 else false
else
std.mem.indexOf(u8, input, ":") != null;
if (std.mem.indexOf(u8, input, ",") != null or
std.mem.indexOf(u8, input, "=") != null or
std.mem.indexOf(u8, input, ":") != null)
has_colon)
{
self.* = try cli.args.parseAutoStruct(
Theme,