summaryrefslogtreecommitdiff
path: root/src/cli
diff options
context:
space:
mode:
authorMitchell Hashimoto <m@mitchellh.com>2024-10-16 16:45:38 -0700
committerMitchell Hashimoto <m@mitchellh.com>2024-10-18 08:11:11 -0700
commita4e14631ef6eb57382ff3c15134d90617c8fd264 (patch)
tree2e407580ccdae9e7aa1568fc0b2039fb7315b74b /src/cli
parent3f1d6eb301a7fb3d967c7f17c555c8dd761d900c (diff)
config: richer diagnostics for errors
Rather than storing a list of errors we now store a list of "diagnostics." Each diagnostic has a richer set of structured information, including a message, a key, the location where it occurred. This lets us show more detailed messages, more human friendly messages, and also let's us filter by key or location. We don't take advantage of all of this capability in this initial commit, but we do use every field for something.
Diffstat (limited to 'src/cli')
-rw-r--r--src/cli/args.zig224
-rw-r--r--src/cli/diagnostics.zig124
-rw-r--r--src/cli/list_themes.zig13
-rw-r--r--src/cli/validate_config.zig11
4 files changed, 252 insertions, 120 deletions
diff --git a/src/cli/args.zig b/src/cli/args.zig
index 2244a801d..c5355251e 100644
--- a/src/cli/args.zig
+++ b/src/cli/args.zig
@@ -3,8 +3,9 @@ const mem = std.mem;
const assert = std.debug.assert;
const Allocator = mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
-
-const ErrorList = @import("../config/ErrorList.zig");
+const diags = @import("diagnostics.zig");
+const Diagnostic = diags.Diagnostic;
+const DiagnosticList = diags.DiagnosticList;
// TODO:
// - Only `--long=value` format is accepted. Do we want to allow
@@ -32,13 +33,18 @@ pub const Error = error{
/// an arena allocator will be created (or reused if set already) for any
/// allocations. Allocations are necessary for certain types, like `[]const u8`.
///
-/// If the destination type has a field "_errors" of type "ErrorList" then
-/// errors will be added to that list. In this case, the only error returned by
-/// parse are allocation errors.
+/// If the destination type has a field "_diagnostics", it must be of type
+/// "DiagnosticList" and any diagnostic messages will be added to that list.
+/// When diagnostics are present, only allocation errors will be returned.
///
/// Note: If the arena is already non-null, then it will be used. In this
/// case, in the case of an error some memory might be leaked into the arena.
-pub fn parse(comptime T: type, alloc: Allocator, dst: *T, iter: anytype) !void {
+pub fn parse(
+ comptime T: type,
+ alloc: Allocator,
+ dst: *T,
+ iter: anytype,
+) !void {
const info = @typeInfo(T);
assert(info == .Struct);
@@ -69,7 +75,11 @@ pub fn parse(comptime T: type, alloc: Allocator, dst: *T, iter: anytype) !void {
while (iter.next()) |arg| {
// Do manual parsing if we have a hook for it.
if (@hasDecl(T, "parseManuallyHook")) {
- if (!try dst.parseManuallyHook(arena_alloc, arg, iter)) return;
+ if (!try dst.parseManuallyHook(
+ arena_alloc,
+ arg,
+ iter,
+ )) return;
}
// If the destination supports help then we check for it, call
@@ -96,56 +106,39 @@ pub fn parse(comptime T: type, alloc: Allocator, dst: *T, iter: anytype) !void {
};
parseIntoField(T, arena_alloc, dst, key, value) catch |err| {
- if (comptime !canTrackErrors(T)) return err;
+ if (comptime !canTrackDiags(T)) return err;
// The error set is dependent on comptime T, so we always add
// an extra error so we can have the "else" below.
const ErrSet = @TypeOf(err) || error{Unknown};
- switch (@as(ErrSet, @errorCast(err))) {
+ const message: [:0]const u8 = switch (@as(ErrSet, @errorCast(err))) {
// OOM is not recoverable since we need to allocate to
// track more error messages.
error.OutOfMemory => return err,
-
- error.InvalidField => try dst._errors.add(arena_alloc, .{
- .message = try std.fmt.allocPrintZ(
- arena_alloc,
- "{s}: unknown field",
- .{key},
- ),
- }),
-
- error.ValueRequired => try dst._errors.add(arena_alloc, .{
- .message = try std.fmt.allocPrintZ(
- arena_alloc,
- "{s}: value required",
- .{key},
- ),
- }),
-
- error.InvalidValue => try dst._errors.add(arena_alloc, .{
- .message = try std.fmt.allocPrintZ(
- arena_alloc,
- "{s}: invalid value",
- .{key},
- ),
- }),
-
- else => try dst._errors.add(arena_alloc, .{
- .message = try std.fmt.allocPrintZ(
- arena_alloc,
- "{s}: unknown error {}",
- .{ key, err },
- ),
- }),
- }
+ error.InvalidField => "unknown field",
+ error.ValueRequired => "value required",
+ error.InvalidValue => "invalid value",
+ else => try std.fmt.allocPrintZ(
+ arena_alloc,
+ "unknown error {}",
+ .{err},
+ ),
+ };
+
+ // Add our diagnostic
+ try dst._diagnostics.append(arena_alloc, .{
+ .key = try arena_alloc.dupeZ(u8, key),
+ .message = message,
+ .location = Diagnostic.Location.fromIter(iter),
+ });
};
}
}
}
-/// Returns true if this type can track errors.
-fn canTrackErrors(comptime T: type) bool {
- return @hasField(T, "_errors");
+/// Returns true if this type can track diagnostics.
+fn canTrackDiags(comptime T: type) bool {
+ return @hasField(T, "_diagnostics");
}
/// Parse a single key/value pair into the destination type T.
@@ -199,15 +192,6 @@ fn parseIntoField(
// 3 arg = (self, alloc, input) => void
3 => try @field(dst, field.name).parseCLI(alloc, value),
- // 4 arg = (self, alloc, errors, input) => void
- 4 => if (comptime canTrackErrors(T)) {
- try @field(dst, field.name).parseCLI(alloc, &dst._errors, value);
- } else {
- var list: ErrorList = .{};
- try @field(dst, field.name).parseCLI(alloc, &list, value);
- if (!list.empty()) return error.InvalidValue;
- },
-
else => @compileError("parseCLI invalid argument count"),
}
@@ -468,7 +452,7 @@ test "parse: empty value resets to default" {
try testing.expect(!data.b);
}
-test "parse: error tracking" {
+test "parse: diagnostic tracking" {
const testing = std.testing;
var data: struct {
@@ -476,7 +460,7 @@ test "parse: error tracking" {
b: enum { one } = .one,
_arena: ?ArenaAllocator = null,
- _errors: ErrorList = .{},
+ _diagnostics: DiagnosticList = .{},
} = .{};
defer if (data._arena) |arena| arena.deinit();
@@ -488,7 +472,48 @@ test "parse: error tracking" {
try parse(@TypeOf(data), testing.allocator, &data, &iter);
try testing.expect(data._arena != null);
try testing.expectEqualStrings("42", data.a);
- try testing.expect(!data._errors.empty());
+ try testing.expect(data._diagnostics.items().len == 1);
+ {
+ const diag = data._diagnostics.items()[0];
+ try testing.expectEqual(Diagnostic.Location.none, diag.location);
+ try testing.expectEqualStrings("what", diag.key);
+ try testing.expectEqualStrings("unknown field", diag.message);
+ }
+}
+
+test "parse: diagnostic location" {
+ const testing = std.testing;
+
+ var data: struct {
+ a: []const u8 = "",
+ b: enum { one, two } = .one,
+
+ _arena: ?ArenaAllocator = null,
+ _diagnostics: DiagnosticList = .{},
+ } = .{};
+ defer if (data._arena) |arena| arena.deinit();
+
+ var fbs = std.io.fixedBufferStream(
+ \\a=42
+ \\what
+ \\b=two
+ );
+ const r = fbs.reader();
+
+ const Iter = LineIterator(@TypeOf(r));
+ var iter: Iter = .{ .r = r, .filepath = "test" };
+ try parse(@TypeOf(data), testing.allocator, &data, &iter);
+ try testing.expect(data._arena != null);
+ try testing.expectEqualStrings("42", data.a);
+ try testing.expect(data.b == .two);
+ try testing.expect(data._diagnostics.items().len == 1);
+ {
+ const diag = data._diagnostics.items()[0];
+ try testing.expectEqualStrings("what", diag.key);
+ try testing.expectEqualStrings("unknown field", diag.message);
+ try testing.expectEqualStrings("test", diag.location.file.path);
+ try testing.expectEqual(2, diag.location.file.line);
+ }
}
test "parseIntoField: ignore underscore-prefixed fields" {
@@ -738,62 +763,6 @@ test "parseIntoField: struct with parse func" {
try testing.expectEqual(@as([]const u8, "HELLO!"), data.a.v);
}
-test "parseIntoField: struct with parse func with error tracking" {
- const testing = std.testing;
- var arena = ArenaAllocator.init(testing.allocator);
- defer arena.deinit();
- const alloc = arena.allocator();
-
- var data: struct {
- a: struct {
- const Self = @This();
-
- pub fn parseCLI(
- _: Self,
- parse_alloc: Allocator,
- errors: *ErrorList,
- value: ?[]const u8,
- ) !void {
- _ = value;
- try errors.add(parse_alloc, .{ .message = "OH NO!" });
- }
- } = .{},
-
- _errors: ErrorList = .{},
- } = .{};
-
- try parseIntoField(@TypeOf(data), alloc, &data, "a", "42");
- try testing.expect(!data._errors.empty());
-}
-
-test "parseIntoField: struct with parse func with unsupported error tracking" {
- const testing = std.testing;
- var arena = ArenaAllocator.init(testing.allocator);
- defer arena.deinit();
- const alloc = arena.allocator();
-
- var data: struct {
- a: struct {
- const Self = @This();
-
- pub fn parseCLI(
- _: Self,
- parse_alloc: Allocator,
- errors: *ErrorList,
- value: ?[]const u8,
- ) !void {
- _ = value;
- try errors.add(parse_alloc, .{ .message = "OH NO!" });
- }
- } = .{},
- } = .{};
-
- try testing.expectError(
- error.InvalidValue,
- parseIntoField(@TypeOf(data), alloc, &data, "a", "42"),
- );
-}
-
test "parseIntoField: tagged union" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
@@ -899,7 +868,21 @@ pub fn LineIterator(comptime ReaderType: type) type {
/// like 4 years and be wrong about this.
pub const MAX_LINE_SIZE = 4096;
+ /// Our stateful reader.
r: ReaderType,
+
+ /// Filepath that is used for diagnostics. This is only used for
+ /// diagnostic messages so it can be formatted however you want.
+ /// It is prefixed to the messages followed by the line number.
+ filepath: []const u8 = "",
+
+ /// The current line that we're on. This is 1-indexed because
+ /// lines are generally 1-indexed in the real world. The value
+ /// can be zero if we haven't read any lines yet.
+ line: usize = 0,
+
+ /// This is the buffer where we store the current entry that
+ /// is formatted to be compatible with the parse function.
entry: [MAX_LINE_SIZE]u8 = [_]u8{ '-', '-' } ++ ([_]u8{0} ** (MAX_LINE_SIZE - 2)),
pub fn next(self: *Self) ?[]const u8 {
@@ -912,6 +895,9 @@ pub fn LineIterator(comptime ReaderType: type) type {
unreachable;
} orelse return null;
+ // Increment our line counter
+ self.line += 1;
+
// Trim any whitespace (including CR) around it
const trim = std.mem.trim(u8, entry, whitespace ++ "\r");
if (trim.len != entry.len) {
@@ -959,6 +945,18 @@ pub fn LineIterator(comptime ReaderType: type) type {
// as CLI args.
return self.entry[0 .. buf.len + 2];
}
+
+ /// Modify the diagnostic so it includes richer context about
+ /// the location of the error.
+ pub fn location(self: *const Self) ?Diagnostic.Location {
+ // If we have no filepath then we have no location.
+ if (self.filepath.len == 0) return null;
+
+ return .{ .file = .{
+ .path = self.filepath,
+ .line = self.line,
+ } };
+ }
};
}
diff --git a/src/cli/diagnostics.zig b/src/cli/diagnostics.zig
new file mode 100644
index 000000000..80dc06b5a
--- /dev/null
+++ b/src/cli/diagnostics.zig
@@ -0,0 +1,124 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const assert = std.debug.assert;
+const Allocator = std.mem.Allocator;
+const build_config = @import("../build_config.zig");
+
+/// A diagnostic message from parsing. This is used to provide additional
+/// human-friendly warnings and errors about the parsed data.
+///
+/// All of the memory for the diagnostic is allocated from the arena
+/// associated with the config structure. If an arena isn't available
+/// then diagnostics are not supported.
+pub const Diagnostic = struct {
+ location: Location = .none,
+ key: [:0]const u8 = "",
+ message: [:0]const u8,
+
+ /// The possible locations for a diagnostic message. This is used
+ /// to provide context for the message.
+ pub const Location = union(enum) {
+ none,
+ cli: usize,
+ file: struct {
+ path: []const u8,
+ line: usize,
+ },
+
+ pub fn fromIter(iter: anytype) Location {
+ const Iter = t: {
+ const T = @TypeOf(iter);
+ break :t switch (@typeInfo(T)) {
+ .Pointer => |v| v.child,
+ .Struct => T,
+ else => return .none,
+ };
+ };
+
+ if (!@hasDecl(Iter, "location")) return .none;
+ return iter.location() orelse .none;
+ }
+ };
+
+ /// Write the full user-friendly diagnostic message to the writer.
+ pub fn write(self: *const Diagnostic, writer: anytype) !void {
+ switch (self.location) {
+ .none => {},
+ .cli => |index| try writer.print("cli:{}:", .{index}),
+ .file => |file| try writer.print(
+ "{s}:{}:",
+ .{ file.path, file.line },
+ ),
+ }
+
+ if (self.key.len > 0) {
+ try writer.print("{s}: ", .{self.key});
+ } else if (self.location != .none) {
+ try writer.print(" ", .{});
+ }
+
+ try writer.print("{s}", .{self.message});
+ }
+};
+
+/// A list of diagnostics. The "_diagnostics" field must be this type
+/// for diagnostics to be supported. If this field is an incorrect type
+/// a compile-time error will be raised.
+///
+/// This is implemented as a simple wrapper around an array list
+/// so that we can inject some logic around adding diagnostics
+/// and potentially in the future structure them differently.
+pub const DiagnosticList = struct {
+ /// The list of diagnostics.
+ list: std.ArrayListUnmanaged(Diagnostic) = .{},
+
+ /// Precomputed data for diagnostics. This is used specifically
+ /// when we build libghostty so that we can precompute the messages
+ /// and return them via the C API without allocating memory at
+ /// call time.
+ precompute: Precompute = precompute_init,
+
+ const precompute_enabled = switch (build_config.artifact) {
+ // We enable precompute for tests so that the logic is
+ // semantically analyzed and run.
+ .exe, .wasm_module => builtin.is_test,
+
+ // We specifically want precompute for libghostty.
+ .lib => true,
+ };
+ const Precompute = if (precompute_enabled) struct {
+ messages: std.ArrayListUnmanaged([:0]const u8) = .{},
+ } else void;
+ const precompute_init: Precompute = if (precompute_enabled) .{} else {};
+
+ pub fn append(
+ self: *DiagnosticList,
+ alloc: Allocator,
+ diag: Diagnostic,
+ ) Allocator.Error!void {
+ try self.list.append(alloc, diag);
+ errdefer _ = self.list.pop();
+
+ if (comptime precompute_enabled) {
+ var buf = std.ArrayList(u8).init(alloc);
+ defer buf.deinit();
+ try diag.write(buf.writer());
+
+ const owned: [:0]const u8 = try buf.toOwnedSliceSentinel(0);
+ errdefer alloc.free(owned);
+
+ try self.precompute.messages.append(alloc, owned);
+ errdefer _ = self.precompute.messages.pop();
+
+ assert(self.precompute.messages.items.len == self.list.items.len);
+ }
+ }
+
+ pub fn empty(self: *const DiagnosticList) bool {
+ return self.list.items.len == 0;
+ }
+
+ pub fn items(self: *DiagnosticList) []Diagnostic {
+ return self.list.items;
+ }
+};
diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig
index 99b419801..feec6fcb0 100644
--- a/src/cli/list_themes.zig
+++ b/src/cli/list_themes.zig
@@ -882,7 +882,7 @@ const Preview = struct {
next_start += child.height;
}
- if (!config._errors.empty()) {
+ if (config._diagnostics.items().len > 0) {
const child = win.child(
.{
.x_off = x_off,
@@ -891,7 +891,7 @@ const Preview = struct {
.limit = width,
},
.height = .{
- .limit = if (config._errors.empty()) 0 else 2 + config._errors.list.items.len,
+ .limit = if (config._diagnostics.items().len == 0) 0 else 2 + config._diagnostics.items().len,
},
},
);
@@ -908,10 +908,14 @@ const Preview = struct {
},
);
}
- for (config._errors.list.items, 0..) |err, i| {
+
+ var buf = std.ArrayList(u8).init(alloc);
+ defer buf.deinit();
+ for (config._diagnostics.items(), 0..) |diag, i| {
+ try diag.write(buf.writer());
_ = try child.printSegment(
.{
- .text = err.message,
+ .text = buf.items,
.style = self.ui_err(),
},
.{
@@ -919,6 +923,7 @@ const Preview = struct {
.col_offset = 2,
},
);
+ buf.clearRetainingCapacity();
}
next_start += child.height;
}
diff --git a/src/cli/validate_config.zig b/src/cli/validate_config.zig
index d6fedc544..ef1dd3ecc 100644
--- a/src/cli/validate_config.zig
+++ b/src/cli/validate_config.zig
@@ -55,9 +55,14 @@ pub fn run(alloc: std.mem.Allocator) !u8 {
try cfg.finalize();
- if (!cfg._errors.empty()) {
- for (cfg._errors.list.items) |err| {
- try stdout.print("{s}\n", .{err.message});
+ if (cfg._diagnostics.items().len > 0) {
+ var buf = std.ArrayList(u8).init(alloc);
+ defer buf.deinit();
+
+ for (cfg._diagnostics.items()) |diag| {
+ try diag.write(buf.writer());
+ try stdout.print("{s}\n", .{buf.items});
+ buf.clearRetainingCapacity();
}
return 1;