diff --git a/src/errors.test.zig b/src/errors.test.zig index 49bef8e..e1c1326 100644 --- a/src/errors.test.zig +++ b/src/errors.test.zig @@ -107,3 +107,150 @@ test "extractContext: span beyond source length is clamped" { try testing.expectEqual(@as(u32, 4), ctx.start_col); try testing.expectEqual(@as(u32, 4), ctx.end_col); } + +// ─── renderExtended tests ──────────────────────────────────────────── + +fn renderToString(dl: *const errors.DiagnosticList, allocator: std.mem.Allocator) ![]u8 { + var aw = std.Io.Writer.Allocating.init(allocator); + try dl.renderExtended(&aw.writer); + var result = aw.writer.toArrayList(); + return result.toOwnedSlice(allocator); +} + +test "renderExtended: single-line span with carets" { + var dl = errors.DiagnosticList.init(testing.allocator, "hello world\n", "test.sx"); + defer dl.deinit(); + dl.add(.err, "test error", Span{ .start = 6, .end = 11 }); + + const output = try renderToString(&dl, testing.allocator); + defer testing.allocator.free(output); + + const expected = + \\error: test error + \\ --> test.sx:1:7 + \\ | + \\ 1 | hello world + \\ | ^^^^^ + \\ + ; + try testing.expectEqualStrings(expected, output); +} + +test "renderExtended: warning level prefix" { + var dl = errors.DiagnosticList.init(testing.allocator, "abc\n", "w.sx"); + defer dl.deinit(); + dl.add(.warn, "soft warning", Span{ .start = 0, .end = 3 }); + + const output = try renderToString(&dl, testing.allocator); + defer testing.allocator.free(output); + + const expected = + \\warning: soft warning + \\ --> w.sx:1:1 + \\ | + \\ 1 | abc + \\ | ^^^ + \\ + ; + try testing.expectEqualStrings(expected, output); +} + +test "renderExtended: diagnostic without span emits header only" { + var dl = errors.DiagnosticList.init(testing.allocator, "", "x.sx"); + defer dl.deinit(); + dl.add(.err, "no source", null); + + const output = try renderToString(&dl, testing.allocator); + defer testing.allocator.free(output); + + const expected = "error: no source\n"; + try testing.expectEqualStrings(expected, output); +} + +test "renderExtended: line-number column widens for triple-digit lines" { + // Build a source with many newlines so the diagnostic lands on line 100. + var src: std.ArrayList(u8) = .empty; + defer src.deinit(testing.allocator); + var i: u32 = 0; + while (i < 99) : (i += 1) try src.append(testing.allocator, '\n'); + try src.appendSlice(testing.allocator, "boom"); + const source = src.items; + + var dl = errors.DiagnosticList.init(testing.allocator, source, "big.sx"); + defer dl.deinit(); + const start: u32 = @intCast(source.len - 4); + const end: u32 = @intCast(source.len); + dl.add(.err, "out here", Span{ .start = start, .end = end }); + + const output = try renderToString(&dl, testing.allocator); + defer testing.allocator.free(output); + + const expected = + \\error: out here + \\ --> big.sx:100:1 + \\ | + \\100 | boom + \\ | ^^^^ + \\ + ; + try testing.expectEqualStrings(expected, output); +} + +test "renderExtended: empty span produces single caret" { + var dl = errors.DiagnosticList.init(testing.allocator, "xyz\n", "p.sx"); + defer dl.deinit(); + dl.add(.err, "point error", Span{ .start = 1, .end = 1 }); + + const output = try renderToString(&dl, testing.allocator); + defer testing.allocator.free(output); + + const expected = + \\error: point error + \\ --> p.sx:1:2 + \\ | + \\ 1 | xyz + \\ | ^ + \\ + ; + try testing.expectEqualStrings(expected, output); +} + +test "renderExtended: multi-line span renders each line with carets" { + var dl = errors.DiagnosticList.init(testing.allocator, "abc\ndef\n", "m.sx"); + defer dl.deinit(); + // span covers "bc\nde" — offsets 1..6. + dl.add(.err, "spans two lines", Span{ .start = 1, .end = 6 }); + + const output = try renderToString(&dl, testing.allocator); + defer testing.allocator.free(output); + + const expected = + \\error: spans two lines + \\ --> m.sx:1:2 + \\ | + \\ 1 | abc + \\ | ^^ + \\ 2 | def + \\ | ^^ + \\ + ; + try testing.expectEqualStrings(expected, output); +} + +test "render: default compact style still works" { + var dl = errors.DiagnosticList.init(testing.allocator, "abc\n", "c.sx"); + defer dl.deinit(); + dl.add(.err, "compact error", Span{ .start = 0, .end = 3 }); + + // Default render_style is .compact — preserves the existing gcc-style. + var aw = std.Io.Writer.Allocating.init(testing.allocator); + defer { + var r = aw.writer.toArrayList(); + r.deinit(testing.allocator); + } + try dl.render(&aw.writer); + var result = aw.writer.toArrayList(); + defer result.deinit(testing.allocator); + + try testing.expectEqualStrings("c.sx:1:1: error: compact error\n", result.items); +} diff --git a/src/errors.zig b/src/errors.zig index 391eebc..ad8ed4a 100644 --- a/src/errors.zig +++ b/src/errors.zig @@ -7,6 +7,11 @@ pub const Level = enum { note, }; +pub const RenderStyle = enum { + compact, + extended, +}; + pub const SourceLoc = struct { line: u32, col: u32, @@ -107,6 +112,7 @@ pub const DiagnosticList = struct { file_name: []const u8, current_source_file: ?[]const u8 = null, import_sources: ?*const std.StringHashMap([:0]const u8) = null, + render_style: RenderStyle = .compact, pub fn init(allocator: std.mem.Allocator, source: []const u8, file_name: []const u8) DiagnosticList { return .{ @@ -161,6 +167,13 @@ pub const DiagnosticList = struct { } pub fn render(self: *const DiagnosticList, writer: anytype) !void { + switch (self.render_style) { + .compact => try self.renderCompact(writer), + .extended => try self.renderExtended(writer), + } + } + + pub fn renderCompact(self: *const DiagnosticList, writer: anytype) !void { for (self.items.items) |d| { const level_str = switch (d.level) { .err => "error", @@ -177,6 +190,68 @@ pub const DiagnosticList = struct { } } + pub fn renderExtended(self: *const DiagnosticList, writer: anytype) !void { + for (self.items.items) |d| { + try self.renderExtendedOne(writer, d); + } + } + + fn renderExtendedOne(self: *const DiagnosticList, writer: anytype, d: Diagnostic) !void { + const level_str = switch (d.level) { + .err => "error", + .warn => "warning", + .note => "note", + }; + + try writer.print("{s}: {s}\n", .{ level_str, d.message }); + + const span = d.span orelse return; + const resolved = self.resolveSourceAndFile(d); + const loc = SourceLoc.compute(resolved.source, span.start); + try writer.print(" --> {s}:{d}:{d}\n", .{ resolved.file_name, loc.line, loc.col }); + + var ctx = extractContext(self.allocator, resolved.source, span) catch return; + defer ctx.deinit(self.allocator); + if (ctx.lines.len == 0) return; + + const max_line_num = ctx.lines[ctx.lines.len - 1].line_num; + const line_num_width = @max(@as(usize, 2), digitCount(max_line_num)); + + try writeRepeated(writer, ' ', line_num_width + 1); + try writer.writeAll("|\n"); + + for (ctx.lines, 0..) |line, idx| { + const pad = line_num_width - digitCount(line.line_num); + try writeRepeated(writer, ' ', pad); + try writer.print("{d} | {s}\n", .{ line.line_num, line.text }); + + try writeRepeated(writer, ' ', line_num_width + 1); + try writer.writeAll("| "); + const col_start: u32 = if (idx == 0) ctx.start_col else 1; + const col_end: u32 = if (idx == ctx.lines.len - 1) + ctx.end_col + else + @as(u32, @intCast(line.text.len)) + 1; + try writeRepeated(writer, ' ', col_start - 1); + const caret_count = if (col_end > col_start) col_end - col_start else 1; + try writeRepeated(writer, '^', caret_count); + try writer.writeAll("\n"); + } + } + + fn digitCount(n: u32) usize { + if (n == 0) return 1; + var count: usize = 0; + var v = n; + while (v > 0) : (v /= 10) count += 1; + return count; + } + + fn writeRepeated(writer: anytype, byte: u8, n: usize) !void { + var i: usize = 0; + while (i < n) : (i += 1) try writer.writeByte(byte); + } + pub fn renderDebug(self: *const DiagnosticList) void { for (self.items.items) |d| { const level_str = switch (d.level) {