From 6767493428d67ec66019d57711882d976d29d2fa Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 8 Mar 2025 15:11:36 -0600 Subject: [PATCH] core: move RepeatablePath to separate file and enable Path as config type Slim down Config.zig by moving RepeatablePath to a separate file and enable the use of Path as it's own config type. --- src/config/Config.zig | 283 +----------------------- src/config/path.zig | 494 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 505 insertions(+), 272 deletions(-) create mode 100644 src/config/path.zig diff --git a/src/config/Config.zig b/src/config/Config.zig index c056b9ab1..e008b2a4e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -35,6 +35,8 @@ const ErrorList = @import("ErrorList.zig"); const MetricModifier = fontpkg.Metrics.Modifier; const help_strings = @import("help_strings"); const RepeatableStringMap = @import("RepeatableStringMap.zig"); +pub const Path = @import("path.zig").Path; +pub const RepeatablePath = @import("path.zig").RepeatablePath; const log = std.log.scoped(.config); @@ -2827,12 +2829,15 @@ fn expandPaths(self: *Config, base: []const u8) !void { // Expand all of our paths inline for (@typeInfo(Config).Struct.fields) |field| { - if (field.type == RepeatablePath) { - try @field(self, field.name).expand( - arena_alloc, - base, - &self._diagnostics, - ); + switch (field.type) { + RepeatablePath, Path => { + try @field(self, field.name).expand( + arena_alloc, + base, + &self._diagnostics, + ); + }, + else => {}, } } } @@ -4111,272 +4116,6 @@ pub const RepeatableString = struct { } }; -/// RepeatablePath is like repeatable string but represents a path value. -/// The difference is that when loading the configuration any values for -/// this will be automatically expanded relative to the path of the config -/// file. -pub const RepeatablePath = struct { - const Self = @This(); - - const Path = union(enum) { - /// No error if the file does not exist. - optional: [:0]const u8, - - /// The file is required to exist. - required: [:0]const u8, - }; - - value: std.ArrayListUnmanaged(Path) = .{}, - - pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { - const value, const optional = if (input) |value| blk: { - if (value.len == 0) { - self.value.clearRetainingCapacity(); - return; - } - - break :blk if (value[0] == '?') - .{ value[1..], true } - else if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') - .{ value[1 .. value.len - 1], false } - else - .{ value, false }; - } else return error.ValueRequired; - - if (value.len == 0) { - // This handles the case of zero length paths after removing any ? - // prefixes or surrounding quotes. In this case, we don't reset the - // list. - return; - } - - const item: Path = if (optional) - .{ .optional = try alloc.dupeZ(u8, value) } - else - .{ .required = try alloc.dupeZ(u8, value) }; - - try self.value.append(alloc, item); - } - - /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { - const value = try self.value.clone(alloc); - for (value.items) |*item| { - switch (item.*) { - .optional, .required => |*path| path.* = try alloc.dupeZ(u8, path.*), - } - } - - return .{ - .value = value, - }; - } - - /// Compare if two of our value are requal. Required by Config. - pub fn equal(self: Self, other: Self) bool { - if (self.value.items.len != other.value.items.len) return false; - for (self.value.items, other.value.items) |a, b| { - if (!std.meta.eql(a, b)) return false; - } - - return true; - } - - /// Used by Formatter - pub fn formatEntry(self: Self, formatter: anytype) !void { - if (self.value.items.len == 0) { - try formatter.formatEntry(void, {}); - return; - } - - var buf: [std.fs.max_path_bytes + 1]u8 = undefined; - for (self.value.items) |item| { - const value = switch (item) { - .optional => |path| std.fmt.bufPrint( - &buf, - "?{s}", - .{path}, - ) catch |err| switch (err) { - // Required for builds on Linux where NoSpaceLeft - // isn't an allowed error for fmt. - error.NoSpaceLeft => return error.OutOfMemory, - }, - .required => |path| path, - }; - - try formatter.formatEntry([]const u8, value); - } - } - - /// Expand all the paths relative to the base directory. - pub fn expand( - self: *Self, - alloc: Allocator, - base: []const u8, - diags: *cli.DiagnosticList, - ) !void { - assert(std.fs.path.isAbsolute(base)); - var dir = try std.fs.cwd().openDir(base, .{}); - defer dir.close(); - - for (0..self.value.items.len) |i| { - const path = switch (self.value.items[i]) { - .optional, .required => |path| path, - }; - - // If it is already absolute we can ignore it. - if (path.len == 0 or std.fs.path.isAbsolute(path)) continue; - - // If it isn't absolute, we need to make it absolute relative - // to the base. - var buf: [std.fs.max_path_bytes]u8 = undefined; - - // Check if the path starts with a tilde and expand it to the - // home directory on Linux/macOS. We explicitly look for "~/" - // because we don't support alternate users such as "~alice/" - if (std.mem.startsWith(u8, path, "~/")) expand: { - // Windows isn't supported yet - if (comptime builtin.os.tag == .windows) break :expand; - - const expanded: []const u8 = internal_os.expandHome( - path, - &buf, - ) catch |err| { - try diags.append(alloc, .{ - .message = try std.fmt.allocPrintZ( - alloc, - "error expanding home directory for path {s}: {}", - .{ path, err }, - ), - }); - - // Blank this path so that we don't attempt to resolve it - // again - self.value.items[i] = .{ .required = "" }; - - continue; - }; - - log.debug( - "expanding file path from home directory: path={s}", - .{expanded}, - ); - - switch (self.value.items[i]) { - .optional, .required => |*p| p.* = try alloc.dupeZ(u8, expanded), - } - - continue; - } - - const abs = dir.realpath(path, &buf) catch |err| abs: { - if (err == error.FileNotFound) { - // The file doesn't exist. Try to resolve the relative path - // another way. - const resolved = try std.fs.path.resolve(alloc, &.{ base, path }); - defer alloc.free(resolved); - @memcpy(buf[0..resolved.len], resolved); - break :abs buf[0..resolved.len]; - } - - try diags.append(alloc, .{ - .message = try std.fmt.allocPrintZ( - alloc, - "error resolving file path {s}: {}", - .{ path, err }, - ), - }); - - // Blank this path so that we don't attempt to resolve it again - self.value.items[i] = .{ .required = "" }; - - continue; - }; - - log.debug( - "expanding file path relative={s} abs={s}", - .{ path, abs }, - ); - - switch (self.value.items[i]) { - .optional, .required => |*p| p.* = try alloc.dupeZ(u8, abs), - } - } - } - - test "parseCLI" { - const testing = std.testing; - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var list: Self = .{}; - try list.parseCLI(alloc, "config.1"); - try list.parseCLI(alloc, "?config.2"); - try list.parseCLI(alloc, "\"?config.3\""); - - // Zero-length values, ignored - try list.parseCLI(alloc, "?"); - try list.parseCLI(alloc, "\"\""); - - try testing.expectEqual(@as(usize, 3), list.value.items.len); - - const Tag = std.meta.Tag(Path); - try testing.expectEqual(Tag.required, @as(Tag, list.value.items[0])); - try testing.expectEqualStrings("config.1", list.value.items[0].required); - - try testing.expectEqual(Tag.optional, @as(Tag, list.value.items[1])); - try testing.expectEqualStrings("config.2", list.value.items[1].optional); - - try testing.expectEqual(Tag.required, @as(Tag, list.value.items[2])); - try testing.expectEqualStrings("?config.3", list.value.items[2].required); - - try list.parseCLI(alloc, ""); - try testing.expectEqual(@as(usize, 0), list.value.items.len); - } - - test "formatConfig empty" { - const testing = std.testing; - var buf = std.ArrayList(u8).init(testing.allocator); - defer buf.deinit(); - - var list: Self = .{}; - try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); - try std.testing.expectEqualSlices(u8, "a = \n", buf.items); - } - - test "formatConfig single item" { - 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: Self = .{}; - try list.parseCLI(alloc, "A"); - try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); - try std.testing.expectEqualSlices(u8, "a = A\n", buf.items); - } - - test "formatConfig multiple items" { - 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: Self = .{}; - try list.parseCLI(alloc, "A"); - try list.parseCLI(alloc, "?B"); - try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); - try std.testing.expectEqualSlices(u8, "a = A\na = ?B\n", buf.items); - } -}; - /// FontVariation is a repeatable configuration value that sets a single /// font variation value. Font variations are configurations for what /// are often called "variable fonts." The font files usually end in diff --git a/src/config/path.zig b/src/config/path.zig new file mode 100644 index 000000000..651dbdb3a --- /dev/null +++ b/src/config/path.zig @@ -0,0 +1,494 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +const cli = @import("../cli.zig"); +const internal_os = @import("../os/main.zig"); +const formatterpkg = @import("formatter.zig"); + +const log = std.log.scoped(.config); + +pub const ParseError = error{ValueRequired} || Allocator.Error; + +/// Path is like a string that represents a path value. The difference is that +/// when loading the configuration the value for this will be automatically +/// expanded relative to the path of the config file (or the home directory). +pub const Path = union(enum) { + /// No error if the file does not exist. + optional: [:0]const u8, + + /// The file is required to exist. + required: [:0]const u8, + + pub fn len(self: Path) usize { + return switch (self) { + inline else => |path| path.len, + }; + } + + pub fn equal(self: Path, other: Path) bool { + return std.meta.eql(self, other); + } + + /// Parse the input and return a Path. A leading `?` indicates that the path + /// is _optional_ and an error should not be logged or displayed to the user + /// if that path does not exist. Otherwise the path is required and an error + /// should be logged if the path does not exist. + pub fn parse( + /// Allocator to use. This must be an arena allocator because we assume + /// that any allocations will be cleaned up when the arena. + arena_alloc: Allocator, + /// The input. + input: ?[]const u8, + ) ParseError!?Path { + var value = input orelse return error.ValueRequired; + + if (value.len == 0) return null; + + const optional = if (value[0] == '?') opt: { + value = value[1..]; + break :opt true; + } else false; + + if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') { + value = value[1 .. value.len - 1]; + } + + if (optional) + return .{ .optional = try arena_alloc.dupeZ(u8, value) } + else + return .{ .required = try arena_alloc.dupeZ(u8, value) }; + } + + /// Parse CLI option. + pub fn parseCLI( + /// The path. The value will be overwritten. + self: *Path, + /// Allocator to use. This must be an arena allocator because we assume + /// that any allocations will be cleaned up when the arena. + arena_alloc: Allocator, + // The input. + input: ?[]const u8, + ) ParseError!void { + assert(input != null); + const item = try parse(arena_alloc, input) orelse return; + if (item.len() == 0) return; + self.* = item; + } + + /// Used by formatter. + pub fn formatEntry(self: *const Path, formatter: anytype) !void { + var buf: [std.fs.max_path_bytes + 1]u8 = undefined; + const value = switch (self.*) { + .optional => |path| std.fmt.bufPrint( + &buf, + "?{s}", + .{path}, + ) catch |err| switch (err) { + // Required for builds on Linux where NoSpaceLeft + // isn't an allowed error for fmt. + error.NoSpaceLeft => return error.OutOfMemory, + }, + .required => |path| path, + }; + + try formatter.formatEntry([]const u8, value); + } + + /// Return a clone of the path. + pub fn clone( + /// The path to clone. + self: Path, + /// This must be an arena allocator because we rely on the arena to + /// clean up our allocations. + arena_alloc: Allocator, + ) Allocator.Error!Path { + return switch (self) { + .optional => |path| .{ + .optional = try arena_alloc.dupeZ(u8, path), + }, + .required => |path| .{ + .required = try arena_alloc.dupeZ(u8, path), + }, + }; + } + + /// Expand relative paths or paths prefixed with `~/`. The path will be + /// overwritten. + pub fn expand( + /// The path to expand. + self: *Path, + /// This must be an arena allocator because we rely on the arena to + /// clean up our allocations. + arena_alloc: Allocator, + /// The base directory to expand relative paths. It must be an absolute + /// path. + base: []const u8, + /// Errors will be added to the list of diagnostics if they occur. + diags: *cli.DiagnosticList, + ) !void { + assert(std.fs.path.isAbsolute(base)); + + const path = switch (self.*) { + .optional, .required => |path| path, + }; + + // If it is already absolute we can ignore it. + if (path.len == 0 or std.fs.path.isAbsolute(path)) return; + + // If it isn't absolute, we need to make it absolute relative + // to the base. + var buf: [std.fs.max_path_bytes]u8 = undefined; + + // Check if the path starts with a tilde and expand it to the + // home directory on Linux/macOS. We explicitly look for "~/" + // because we don't support alternate users such as "~alice/" + if (std.mem.startsWith(u8, path, "~/")) expand: { + // Windows isn't supported yet + if (comptime builtin.os.tag == .windows) break :expand; + + const expanded: []const u8 = internal_os.expandHome( + path, + &buf, + ) catch |err| { + try diags.append(arena_alloc, .{ + .message = try std.fmt.allocPrintZ( + arena_alloc, + "error expanding home directory for path {s}: {}", + .{ path, err }, + ), + }); + + // Blank this path so that we don't attempt to resolve it + // again + self.* = .{ .required = "" }; + + return; + }; + + log.debug( + "expanding file path from home directory: path={s}", + .{expanded}, + ); + + switch (self.*) { + .optional, .required => |*p| p.* = try arena_alloc.dupeZ(u8, expanded), + } + + return; + } + + var dir = try std.fs.openDirAbsolute(base, .{}); + defer dir.close(); + + const abs = dir.realpath(path, &buf) catch |err| abs: { + if (err == error.FileNotFound) { + // The file doesn't exist. Try to resolve the relative path + // another way. + const resolved = try std.fs.path.resolve(arena_alloc, &.{ base, path }); + defer arena_alloc.free(resolved); + @memcpy(buf[0..resolved.len], resolved); + break :abs buf[0..resolved.len]; + } + + try diags.append(arena_alloc, .{ + .message = try std.fmt.allocPrintZ( + arena_alloc, + "error resolving file path {s}: {}", + .{ path, err }, + ), + }); + + // Blank this path so that we don't attempt to resolve it again + self.* = .{ .required = "" }; + + return; + }; + + log.debug( + "expanding file path relative={s} abs={s}", + .{ path, abs }, + ); + + switch (self.*) { + .optional, .required => |*p| p.* = try arena_alloc.dupeZ(u8, abs), + } + } + + test "parse" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const Tag = std.meta.Tag(Path); + + { + const item = (try Path.parse(alloc, "config.1")).?; + try testing.expectEqual(Tag.required, @as(Tag, item)); + try testing.expectEqualStrings("config.1", item.required); + } + + { + const item = (try Path.parse(alloc, "?config.2")).?; + try testing.expectEqual(Tag.optional, @as(Tag, item)); + try testing.expectEqualStrings("config.2", item.optional); + } + + { + const item = (try Path.parse(alloc, "\"?config.3\"")).?; + try testing.expectEqual(Tag.required, @as(Tag, item)); + try testing.expectEqualStrings("?config.3", item.required); + } + + { + const item = (try Path.parse(alloc, "?\"config.4\"")).?; + try testing.expectEqual(Tag.optional, @as(Tag, item)); + try testing.expectEqualStrings("config.4", item.optional); + } + + { + const item = (try Path.parse(alloc, "?")).?; + try testing.expectEqual(Tag.optional, @as(Tag, item)); + try testing.expectEqualStrings("", item.optional); + } + + { + const item = (try Path.parse(alloc, "\"\"")).?; + try testing.expectEqual(Tag.required, @as(Tag, item)); + try testing.expectEqualStrings("", item.required); + } + + { + const item = (try Path.parse(alloc, "?\"\"")).?; + try testing.expectEqual(Tag.optional, @as(Tag, item)); + try testing.expectEqualStrings("", item.optional); + } + } + + test "parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const Tag = std.meta.Tag(Path); + var item: Path = undefined; + + try item.parseCLI(alloc, "config.1"); + try testing.expectEqual(Tag.required, @as(Tag, item)); + try testing.expectEqualStrings("config.1", item.required); + + try item.parseCLI(alloc, "?config.2"); + try testing.expectEqual(Tag.optional, @as(Tag, item)); + try testing.expectEqualStrings("config.2", item.optional); + + try item.parseCLI(alloc, "\"?config.3\""); + try testing.expectEqual(Tag.required, @as(Tag, item)); + try testing.expectEqualStrings("?config.3", item.required); + + // Zero-length values, ignored + + try item.parseCLI(alloc, "?"); + try testing.expectEqual(Tag.required, @as(Tag, item)); + try testing.expectEqualStrings("?config.3", item.required); + + try item.parseCLI(alloc, "\"\""); + try testing.expectEqual(Tag.required, @as(Tag, item)); + try testing.expectEqualStrings("?config.3", item.required); + + try item.parseCLI(alloc, "?\"\""); + try testing.expectEqual(Tag.required, @as(Tag, item)); + try testing.expectEqualStrings("?config.3", item.required); + } + + test "formatConfig single item" { + 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 item: Path = undefined; + try item.parseCLI(alloc, "A"); + try item.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = A\n", buf.items); + } + + test "formatConfig multiple items" { + 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 item: Path = undefined; + try item.parseCLI(alloc, "A"); + try item.parseCLI(alloc, "?B"); + try item.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = ?B\n", buf.items); + } +}; + +/// RepeatablePath is like repeatable string but represents a path value. The +/// difference is that when loading the configuration any values for this will +/// be automatically expanded relative to the path of the config file (or the home +/// directory). +pub const RepeatablePath = struct { + value: std.ArrayListUnmanaged(Path) = .{}, + + pub fn parseCLI(self: *RepeatablePath, alloc: Allocator, input: ?[]const u8) ParseError!void { + const item = try Path.parse(alloc, input) orelse { + self.value.clearRetainingCapacity(); + return; + }; + + if (item.len() == 0) { + // This handles the case of zero length paths after removing any ? + // prefixes or surrounding quotes. In this case, we don't reset the + // list. + return; + } + + try self.value.append(alloc, item); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const RepeatablePath, alloc: Allocator) Allocator.Error!RepeatablePath { + const value = try self.value.clone(alloc); + for (value.items) |*item| { + item.* = try item.clone(alloc); + } + + return .{ + .value = value, + }; + } + + /// Compare if two of our value are equal. Required by Config. + pub fn equal(self: RepeatablePath, other: RepeatablePath) bool { + if (self.value.items.len != other.value.items.len) return false; + for (self.value.items, other.value.items) |a, b| { + if (!a.equal(b)) return false; + } + + return true; + } + + /// Used by Formatter + pub fn formatEntry(self: RepeatablePath, formatter: anytype) !void { + if (self.value.items.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + var buf: [std.fs.max_path_bytes + 1]u8 = undefined; + for (self.value.items) |item| { + const value = switch (item) { + .optional => |path| std.fmt.bufPrint( + &buf, + "?{s}", + .{path}, + ) catch |err| switch (err) { + // Required for builds on Linux where NoSpaceLeft + // isn't an allowed error for fmt. + error.NoSpaceLeft => return error.OutOfMemory, + }, + .required => |path| path, + }; + + try formatter.formatEntry([]const u8, value); + } + } + + /// Expand all the paths relative to the base directory. + pub fn expand( + self: *RepeatablePath, + alloc: Allocator, + base: []const u8, + diags: *cli.DiagnosticList, + ) !void { + for (self.value.items) |*path| { + try path.expand(alloc, base, diags); + } + } + test "parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatablePath = .{}; + try list.parseCLI(alloc, "config.1"); + try list.parseCLI(alloc, "?config.2"); + try list.parseCLI(alloc, "\"?config.3\""); + + try testing.expectEqual(@as(usize, 3), list.value.items.len); + + // Zero-length values, ignored + try list.parseCLI(alloc, "?"); + try list.parseCLI(alloc, "\"\""); + + try testing.expectEqual(@as(usize, 3), list.value.items.len); + + const Tag = std.meta.Tag(Path); + try testing.expectEqual(Tag.required, @as(Tag, list.value.items[0])); + try testing.expectEqualStrings("config.1", list.value.items[0].required); + + try testing.expectEqual(Tag.optional, @as(Tag, list.value.items[1])); + try testing.expectEqualStrings("config.2", list.value.items[1].optional); + + try testing.expectEqual(Tag.required, @as(Tag, list.value.items[2])); + try testing.expectEqualStrings("?config.3", list.value.items[2].required); + + try list.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), list.value.items.len); + } + + test "formatConfig empty" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var list: RepeatablePath = .{}; + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = \n", buf.items); + } + + test "formatConfig single item" { + 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: RepeatablePath = .{}; + try list.parseCLI(alloc, "A"); + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = A\n", buf.items); + } + + test "formatConfig multiple items" { + 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: RepeatablePath = .{}; + try list.parseCLI(alloc, "A"); + try list.parseCLI(alloc, "?B"); + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = A\na = ?B\n", buf.items); + } +};