Feature 0 complete. addNote/addHelp bundle notes and help-blocks under a primary diagnostic (handle from new addId/addFmtId); help blocks carry an optional fix-it line that substitutes the suggested source. renderExtended now renders primary -> notes -> helps with blank-line separators. Wire the CLI to the extended renderer (renderErrors -> renderStderr) and flip render_style default to .extended; the previous renderErrors -> renderDebug path bypassed render() entirely, so flipping the field alone was a no-op. 13 diagnostic snapshots re-rendered to the extended format.
361 lines
12 KiB
Zig
361 lines
12 KiB
Zig
const std = @import("std");
|
|
const testing = std.testing;
|
|
const errors = @import("errors.zig");
|
|
const ast = @import("ast.zig");
|
|
const Span = ast.Span;
|
|
|
|
test "extractContext: single-line span at start of file" {
|
|
const source = "hello world\nfoo bar\n";
|
|
var ctx = try errors.extractContext(testing.allocator, source, Span{ .start = 0, .end = 5 });
|
|
defer ctx.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 1), ctx.lines.len);
|
|
try testing.expectEqual(@as(u32, 1), ctx.lines[0].line_num);
|
|
try testing.expectEqualStrings("hello world", ctx.lines[0].text);
|
|
try testing.expectEqual(@as(u32, 1), ctx.start_col);
|
|
try testing.expectEqual(@as(u32, 6), ctx.end_col);
|
|
}
|
|
|
|
test "extractContext: single-line span in middle of file" {
|
|
const source = "first\nsecond line here\nthird";
|
|
// Offsets: "first" = 0..5, '\n' = 5, "second line here" = 6..22.
|
|
// span covers "line" at offsets 13..17.
|
|
var ctx = try errors.extractContext(testing.allocator, source, Span{ .start = 13, .end = 17 });
|
|
defer ctx.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 1), ctx.lines.len);
|
|
try testing.expectEqual(@as(u32, 2), ctx.lines[0].line_num);
|
|
try testing.expectEqualStrings("second line here", ctx.lines[0].text);
|
|
try testing.expectEqual(@as(u32, 8), ctx.start_col);
|
|
try testing.expectEqual(@as(u32, 12), ctx.end_col);
|
|
}
|
|
|
|
test "extractContext: multi-line span crossing one newline" {
|
|
const source = "line1\nline2\nline3";
|
|
// Span covers "e1\nlin" at offsets 3..9.
|
|
var ctx = try errors.extractContext(testing.allocator, source, Span{ .start = 3, .end = 9 });
|
|
defer ctx.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 2), ctx.lines.len);
|
|
try testing.expectEqual(@as(u32, 1), ctx.lines[0].line_num);
|
|
try testing.expectEqualStrings("line1", ctx.lines[0].text);
|
|
try testing.expectEqual(@as(u32, 2), ctx.lines[1].line_num);
|
|
try testing.expectEqualStrings("line2", ctx.lines[1].text);
|
|
try testing.expectEqual(@as(u32, 4), ctx.start_col);
|
|
try testing.expectEqual(@as(u32, 4), ctx.end_col);
|
|
}
|
|
|
|
test "extractContext: multi-line span crossing two newlines" {
|
|
const source = "line1\nline2\nline3\nline4";
|
|
// Span covers offsets 3..14: "e1\nline2\nli".
|
|
var ctx = try errors.extractContext(testing.allocator, source, Span{ .start = 3, .end = 14 });
|
|
defer ctx.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 3), ctx.lines.len);
|
|
try testing.expectEqualStrings("line1", ctx.lines[0].text);
|
|
try testing.expectEqualStrings("line2", ctx.lines[1].text);
|
|
try testing.expectEqualStrings("line3", ctx.lines[2].text);
|
|
try testing.expectEqual(@as(u32, 4), ctx.start_col);
|
|
try testing.expectEqual(@as(u32, 3), ctx.end_col);
|
|
}
|
|
|
|
test "extractContext: span on empty line" {
|
|
const source = "before\n\nafter";
|
|
// Empty middle line at offset 7 (after the first '\n').
|
|
var ctx = try errors.extractContext(testing.allocator, source, Span{ .start = 7, .end = 7 });
|
|
defer ctx.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 1), ctx.lines.len);
|
|
try testing.expectEqual(@as(u32, 2), ctx.lines[0].line_num);
|
|
try testing.expectEqualStrings("", ctx.lines[0].text);
|
|
try testing.expectEqual(@as(u32, 1), ctx.start_col);
|
|
try testing.expectEqual(@as(u32, 1), ctx.end_col);
|
|
}
|
|
|
|
test "extractContext: span at end of file (no trailing newline)" {
|
|
const source = "foo\nbar";
|
|
// Span covers "ar" at offsets 5..7.
|
|
var ctx = try errors.extractContext(testing.allocator, source, Span{ .start = 5, .end = 7 });
|
|
defer ctx.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 1), ctx.lines.len);
|
|
try testing.expectEqual(@as(u32, 2), ctx.lines[0].line_num);
|
|
try testing.expectEqualStrings("bar", ctx.lines[0].text);
|
|
try testing.expectEqual(@as(u32, 2), ctx.start_col);
|
|
try testing.expectEqual(@as(u32, 4), ctx.end_col);
|
|
}
|
|
|
|
test "extractContext: empty source" {
|
|
const source = "";
|
|
var ctx = try errors.extractContext(testing.allocator, source, Span{ .start = 0, .end = 0 });
|
|
defer ctx.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 1), ctx.lines.len);
|
|
try testing.expectEqual(@as(u32, 1), ctx.lines[0].line_num);
|
|
try testing.expectEqualStrings("", ctx.lines[0].text);
|
|
try testing.expectEqual(@as(u32, 1), ctx.start_col);
|
|
try testing.expectEqual(@as(u32, 1), ctx.end_col);
|
|
}
|
|
|
|
test "extractContext: span beyond source length is clamped" {
|
|
const source = "foo";
|
|
var ctx = try errors.extractContext(testing.allocator, source, Span{ .start = 10, .end = 20 });
|
|
defer ctx.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 1), ctx.lines.len);
|
|
try testing.expectEqualStrings("foo", ctx.lines[0].text);
|
|
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 "renderExtended: primary bundled with one note" {
|
|
var dl = errors.DiagnosticList.init(testing.allocator, "let x = y\nlet y = z\n", "b.sx");
|
|
defer dl.deinit();
|
|
const p = dl.addId(.err, "undefined name", Span{ .start = 8, .end = 9 });
|
|
dl.addNote(p, Span{ .start = 14, .end = 15 }, "declared here");
|
|
|
|
const output = try renderToString(&dl, testing.allocator);
|
|
defer testing.allocator.free(output);
|
|
|
|
const expected =
|
|
\\error: undefined name
|
|
\\ --> b.sx:1:9
|
|
\\ |
|
|
\\ 1 | let x = y
|
|
\\ | ^
|
|
\\
|
|
\\note: declared here
|
|
\\ --> b.sx:2:5
|
|
\\ |
|
|
\\ 2 | let y = z
|
|
\\ | ^
|
|
\\
|
|
;
|
|
try testing.expectEqualStrings(expected, output);
|
|
}
|
|
|
|
test "renderExtended: primary bundled with help (no fix code)" {
|
|
var dl = errors.DiagnosticList.init(testing.allocator, "foo bar\n", "h.sx");
|
|
defer dl.deinit();
|
|
const p = dl.addId(.err, "bad thing", Span{ .start = 0, .end = 3 });
|
|
dl.addHelp(p, null, "try something else", null);
|
|
|
|
const output = try renderToString(&dl, testing.allocator);
|
|
defer testing.allocator.free(output);
|
|
|
|
const expected =
|
|
\\error: bad thing
|
|
\\ --> h.sx:1:1
|
|
\\ |
|
|
\\ 1 | foo bar
|
|
\\ | ^^^
|
|
\\
|
|
\\help: try something else
|
|
\\
|
|
;
|
|
try testing.expectEqualStrings(expected, output);
|
|
}
|
|
|
|
test "renderExtended: help with fix-it code substitutes the line and omits arrow" {
|
|
var dl = errors.DiagnosticList.init(testing.allocator, " x := foo.value\n", "f.sx");
|
|
defer dl.deinit();
|
|
// primary span covers "value" at columns 12..17.
|
|
const p = dl.addId(.err, "no such field", Span{ .start = 11, .end = 16 });
|
|
dl.addHelp(p, Span{ .start = 11, .end = 16 }, "did you mean `val`?", " x := foo.val");
|
|
|
|
const output = try renderToString(&dl, testing.allocator);
|
|
defer testing.allocator.free(output);
|
|
|
|
const expected =
|
|
\\error: no such field
|
|
\\ --> f.sx:1:12
|
|
\\ |
|
|
\\ 1 | x := foo.value
|
|
\\ | ^^^^^
|
|
\\
|
|
\\help: did you mean `val`?
|
|
\\ |
|
|
\\ 1 | x := foo.val
|
|
\\ | ^^^^^
|
|
\\
|
|
;
|
|
try testing.expectEqualStrings(expected, output);
|
|
}
|
|
|
|
test "renderExtended: note and help bundle in note-then-help order" {
|
|
var dl = errors.DiagnosticList.init(testing.allocator, "aaa\n", "o.sx");
|
|
defer dl.deinit();
|
|
const p = dl.addId(.err, "primary", Span{ .start = 0, .end = 3 });
|
|
// Add help first, then note: rendering must still emit note before help.
|
|
dl.addHelp(p, null, "the help", null);
|
|
dl.addNote(p, Span{ .start = 0, .end = 3 }, "the note");
|
|
|
|
const output = try renderToString(&dl, testing.allocator);
|
|
defer testing.allocator.free(output);
|
|
|
|
const expected =
|
|
\\error: primary
|
|
\\ --> o.sx:1:1
|
|
\\ |
|
|
\\ 1 | aaa
|
|
\\ | ^^^
|
|
\\
|
|
\\note: the note
|
|
\\ --> o.sx:1:1
|
|
\\ |
|
|
\\ 1 | aaa
|
|
\\ | ^^^
|
|
\\
|
|
\\help: the help
|
|
\\
|
|
;
|
|
try testing.expectEqualStrings(expected, output);
|
|
}
|
|
|
|
test "render: compact style still available behind the flag" {
|
|
var dl = errors.DiagnosticList.init(testing.allocator, "abc\n", "c.sx");
|
|
defer dl.deinit();
|
|
dl.render_style = .compact;
|
|
dl.add(.err, "compact error", Span{ .start = 0, .end = 3 });
|
|
|
|
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);
|
|
}
|