lang F0.3: multi-message diagnostic bundling + help-blocks

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.
This commit is contained in:
agra
2026-05-29 09:36:53 +03:00
parent cc08f9a9fe
commit 9bf3dc75e6
17 changed files with 328 additions and 44 deletions

View File

@@ -371,6 +371,6 @@ pub const Compilation = struct {
}
pub fn renderErrors(self: *const Compilation) void {
self.diagnostics.renderDebug();
self.diagnostics.renderStderr();
}
};

View File

@@ -237,12 +237,116 @@ test "renderExtended: multi-line span renders each line with carets" {
try testing.expectEqualStrings(expected, output);
}
test "render: default compact style still works" {
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 });
// 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();

View File

@@ -5,6 +5,7 @@ pub const Level = enum {
err,
warn,
note,
help,
};
pub const RenderStyle = enum {
@@ -103,6 +104,15 @@ pub const Diagnostic = struct {
message: []const u8,
span: ?Span,
source_file: ?[]const u8 = null,
/// Index into `DiagnosticList.items` of the primary `err`/`warn` this
/// note/help is bundled under. `null` for a standalone primary.
primary: ?usize = null,
/// For a `.help`: the suggested replacement source shown on the excerpt
/// line in place of the original code. `null` renders the help as a bare
/// suggestion line with no code block.
fix_code: ?[]const u8 = null,
};
pub const DiagnosticList = struct {
@@ -112,7 +122,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,
render_style: RenderStyle = .extended,
pub fn init(allocator: std.mem.Allocator, source: []const u8, file_name: []const u8) DiagnosticList {
return .{
@@ -127,25 +137,75 @@ pub const DiagnosticList = struct {
}
pub fn add(self: *DiagnosticList, level: Level, message: []const u8, span: ?Span) void {
_ = self.addId(level, message, span);
}
/// Append a primary diagnostic, returning its index in `items`. The index
/// is the handle passed to `addNote` / `addHelp` to bundle related
/// messages under it. On a deduplicated hit the existing index is
/// returned, so bundling still attaches to the surviving primary.
pub fn addId(self: *DiagnosticList, level: Level, message: []const u8, span: ?Span) usize {
// Deduplicate: skip if same level+span+message already exists
for (self.items.items) |d| {
for (self.items.items, 0..) |d, i| {
if (d.level == level and std.mem.eql(u8, d.message, message)) {
const a = d.span orelse continue;
const b = span orelse continue;
if (a.start == b.start and a.end == b.end) return;
if (a.start == b.start and a.end == b.end) return i;
}
}
const idx = self.items.items.len;
self.items.append(self.allocator, .{
.level = level,
.message = message,
.span = span,
.source_file = self.current_source_file,
}) catch {};
}) catch return idx;
return idx;
}
pub fn addFmt(self: *DiagnosticList, level: Level, span: ?Span, comptime fmt: []const u8, args: anytype) void {
_ = self.addFmtId(level, span, fmt, args);
}
pub fn addFmtId(self: *DiagnosticList, level: Level, span: ?Span, comptime fmt: []const u8, args: anytype) usize {
const message = std.fmt.allocPrint(self.allocator, fmt, args) catch "diagnostic format error";
self.add(level, message, span);
return self.addId(level, message, span);
}
/// Attach a `note:` block to the primary diagnostic at `primary_id`.
/// Notes render after the primary, in insertion order, before any helps.
pub fn addNote(self: *DiagnosticList, primary_id: usize, span: ?Span, message: []const u8) void {
self.items.append(self.allocator, .{
.level = .note,
.message = message,
.span = span,
.source_file = self.current_source_file,
.primary = primary_id,
}) catch {};
}
pub fn addNoteFmt(self: *DiagnosticList, primary_id: usize, span: ?Span, comptime fmt: []const u8, args: anytype) void {
const message = std.fmt.allocPrint(self.allocator, fmt, args) catch "diagnostic format error";
self.addNote(primary_id, span, message);
}
/// Attach a `help:` block to the primary diagnostic at `primary_id`.
/// When `fix_code` is non-null it is shown on the excerpt line in place of
/// the original source as a fix-it suggestion.
pub fn addHelp(self: *DiagnosticList, primary_id: usize, span: ?Span, message: []const u8, fix_code: ?[]const u8) void {
self.items.append(self.allocator, .{
.level = .help,
.message = message,
.span = span,
.source_file = self.current_source_file,
.primary = primary_id,
.fix_code = fix_code,
}) catch {};
}
pub fn addHelpFmt(self: *DiagnosticList, primary_id: usize, span: ?Span, fix_code: ?[]const u8, comptime fmt: []const u8, args: anytype) void {
const message = std.fmt.allocPrint(self.allocator, fmt, args) catch "diagnostic format error";
self.addHelp(primary_id, span, message, fix_code);
}
pub fn hasErrors(self: *const DiagnosticList) bool {
@@ -175,11 +235,7 @@ pub const DiagnosticList = struct {
pub fn renderCompact(self: *const DiagnosticList, writer: anytype) !void {
for (self.items.items) |d| {
const level_str = switch (d.level) {
.err => "error",
.warn => "warning",
.note => "note",
};
const level_str = levelStr(d.level);
if (d.span) |span| {
const resolved = self.resolveSourceAndFile(d);
const loc = SourceLoc.compute(resolved.source, span.start);
@@ -190,25 +246,47 @@ pub const DiagnosticList = struct {
}
}
/// Render every primary diagnostic, each followed by its bundled notes
/// (insertion order) and then its bundled helps. Bundles are separated by
/// a blank line; blocks within a bundle are too.
pub fn renderExtended(self: *const DiagnosticList, writer: anytype) !void {
for (self.items.items) |d| {
try self.renderExtendedOne(writer, d);
var first = true;
for (self.items.items, 0..) |d, i| {
if (d.primary != null) continue;
if (!first) try writer.writeAll("\n");
first = false;
try self.renderBlock(writer, d, true);
for (self.items.items) |n| {
if (n.primary == i and n.level == .note) {
try writer.writeAll("\n");
try self.renderBlock(writer, n, true);
}
}
for (self.items.items) |h| {
if (h.primary == i and h.level == .help) {
try writer.writeAll("\n");
try self.renderBlock(writer, h, false);
}
}
}
}
fn renderExtendedOne(self: *const DiagnosticList, writer: anytype, d: Diagnostic) !void {
const level_str = switch (d.level) {
.err => "error",
.warn => "warning",
.note => "note",
};
/// Render one diagnostic as a header line, optional `-->` location arrow,
/// and a code excerpt with caret underline. A `.help` with `fix_code`
/// substitutes the suggested source on the excerpt line and omits the
/// arrow (`show_arrow == false`).
fn renderBlock(self: *const DiagnosticList, writer: anytype, d: Diagnostic, show_arrow: bool) !void {
const level_str = levelStr(d.level);
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 });
if (show_arrow) {
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);
@@ -220,6 +298,23 @@ pub const DiagnosticList = struct {
try writeRepeated(writer, ' ', line_num_width + 1);
try writer.writeAll("|\n");
// Fix-it: show the suggested replacement on the primary span's line
// instead of the original source, with carets under the change.
if (d.fix_code) |fix| {
const ln = ctx.lines[0].line_num;
const pad = line_num_width - digitCount(ln);
try writeRepeated(writer, ' ', pad);
try writer.print("{d} | {s}\n", .{ ln, fix });
try writeRepeated(writer, ' ', line_num_width + 1);
try writer.writeAll("| ");
try writeRepeated(writer, ' ', ctx.start_col - 1);
const caret_count = if (ctx.end_col > ctx.start_col) ctx.end_col - ctx.start_col else 1;
try writeRepeated(writer, '^', caret_count);
try writer.writeAll("\n");
return;
}
for (ctx.lines, 0..) |line, idx| {
const pad = line_num_width - digitCount(line.line_num);
try writeRepeated(writer, ' ', pad);
@@ -239,6 +334,15 @@ pub const DiagnosticList = struct {
}
}
fn levelStr(level: Level) []const u8 {
return switch (level) {
.err => "error",
.warn => "warning",
.note => "note",
.help => "help",
};
}
fn digitCount(n: u32) usize {
if (n == 0) return 1;
var count: usize = 0;
@@ -252,13 +356,22 @@ pub const DiagnosticList = struct {
while (i < n) : (i += 1) try writer.writeByte(byte);
}
/// Render diagnostics to stderr respecting `render_style`. This is the
/// path the CLI uses; `renderDebug` remains a compact fallback.
pub fn renderStderr(self: *const DiagnosticList) void {
var aw = std.Io.Writer.Allocating.init(self.allocator);
self.render(&aw.writer) catch {
self.renderDebug();
return;
};
var result = aw.writer.toArrayList();
defer result.deinit(self.allocator);
std.debug.print("{s}", .{result.items});
}
pub fn renderDebug(self: *const DiagnosticList) void {
for (self.items.items) |d| {
const level_str = switch (d.level) {
.err => "error",
.warn => "warning",
.note => "note",
};
const level_str = levelStr(d.level);
if (d.span) |span| {
const resolved = self.resolveSourceAndFile(d);
const loc = SourceLoc.compute(resolved.source, span.start);

View File

@@ -1749,6 +1749,7 @@ pub const Server = struct {
.err => 1,
.warn => 2,
.note => 3,
.help => 4,
};
result.append(allocator, .{
.range = range,

View File

@@ -1,3 +1,17 @@
/Users/agra/projects/sx/examples/127-import-non-transitive.sx:15:37: error: 'c_only_fn' is not visible; #import the module that declares it
/Users/agra/projects/sx/examples/127-import-non-transitive.sx:16:40: error: 'c_only_const' is not visible; #import the module that declares it
/Users/agra/projects/sx/examples/127-import-non-transitive.sx:16:40: error: unresolved 'c_only_const' (in /Users/agra/projects/sx/examples/127-import-non-transitive.sx fn main)
error: 'c_only_fn' is not visible; #import the module that declares it
--> /Users/agra/projects/sx/examples/127-import-non-transitive.sx:15:37
|
15 | print("c_only_fn direct: {}\n", c_only_fn());
| ^^^^^^^^^
error: 'c_only_const' is not visible; #import the module that declares it
--> /Users/agra/projects/sx/examples/127-import-non-transitive.sx:16:40
|
16 | print("c_only_const direct: {}\n", c_only_const);
| ^^^^^^^^^^^^
error: unresolved 'c_only_const' (in /Users/agra/projects/sx/examples/127-import-non-transitive.sx fn main)
--> /Users/agra/projects/sx/examples/127-import-non-transitive.sx:16:40
|
16 | print("c_only_const direct: {}\n", c_only_const);
| ^^^^^^^^^^^^

View File

@@ -1 +1,5 @@
/Users/agra/projects/sx/examples/131-callconv-mismatch-diagnostic.sx:12:42: error: call-convention mismatch: 'sx_handler' is declared with default sx convention but the target type expects callconv(.c)
error: call-convention mismatch: 'sx_handler' is declared with default sx convention but the target type expects callconv(.c)
--> /Users/agra/projects/sx/examples/131-callconv-mismatch-diagnostic.sx:12:42
|
12 | fp : (*void) -> *void callconv(.c) = sx_handler;
| ^^^^^^^^^^

View File

@@ -1 +1,5 @@
/Users/agra/projects/sx/examples/161-pack-index-oob.sx:14:32: error: pack index 2 out of bounds: 'args' has 1 element
error: pack index 2 out of bounds: 'args' has 1 element
--> /Users/agra/projects/sx/examples/161-pack-index-oob.sx:14:32
|
14 | foo :: (..$args) -> $R => args[2];
| ^

View File

@@ -1 +1,5 @@
/Users/agra/projects/sx/examples/./179-impl-visibility-user.sx:7:17: error: no visible xx conversion from 's64' to 'Wrap' — impl exists in another module but is not imported
error: no visible xx conversion from 's64' to 'Wrap' — impl exists in another module but is not imported
--> /Users/agra/projects/sx/examples/./179-impl-visibility-user.sx:7:17
|
7 | w : Wrap = xx 7;
| ^

View File

@@ -1 +1,5 @@
/Users/agra/projects/sx/examples/180-impl-duplicate.sx:20:17: error: duplicate xx conversion from 's64' to 'Wrap': impls in /Users/agra/projects/sx/examples/./180-impl-duplicate-impl-a.sx and /Users/agra/projects/sx/examples/./180-impl-duplicate-impl-b.sx
error: duplicate xx conversion from 's64' to 'Wrap': impls in /Users/agra/projects/sx/examples/./180-impl-duplicate-impl-a.sx and /Users/agra/projects/sx/examples/./180-impl-duplicate-impl-b.sx
--> /Users/agra/projects/sx/examples/180-impl-duplicate.sx:20:17
|
20 | w : Wrap = xx 7;
| ^

View File

@@ -1 +1,9 @@
/Users/agra/projects/sx/examples/181-impl-duplicate-same-file.sx:15:1: error: duplicate impl 'Into' for source 's64' in /Users/agra/projects/sx/examples/181-impl-duplicate-same-file.sx
error: duplicate impl 'Into' for source 's64' in /Users/agra/projects/sx/examples/181-impl-duplicate-same-file.sx
--> /Users/agra/projects/sx/examples/181-impl-duplicate-same-file.sx:15:1
|
15 | impl Into(MyA) for s64 {
| ^^^^^^^^^^^^^^^^^^^^^^^^
16 | convert :: (self: s64) -> MyA { .{ v = self * 2 }; }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
17 | }
| ^

View File

@@ -1 +1,5 @@
/Users/agra/projects/sx/examples/187-compile-error.sx:12:6: error: intentional compile error from #run
error: intentional compile error from #run
--> /Users/agra/projects/sx/examples/187-compile-error.sx:12:6
|
12 | #run compile_error("intentional compile error from #run");
| ^^^^^^^^^^^^^

View File

@@ -1 +1,5 @@
/Users/agra/projects/sx/examples/55-err-field-not-found.sx:8:15: error: field 'bogus' not found on type 'Vec'
error: field 'bogus' not found on type 'Vec'
--> /Users/agra/projects/sx/examples/55-err-field-not-found.sx:8:15
|
8 | return xx v.bogus;
| ^^^^^^^

View File

@@ -1 +1,5 @@
/Users/agra/projects/sx/examples/56-err-tuple-oob.sx:6:15: error: field '42' not found on type 'tuple'
error: field '42' not found on type 'tuple'
--> /Users/agra/projects/sx/examples/56-err-tuple-oob.sx:6:15
|
6 | return xx t.42;
| ^^^^

View File

@@ -1 +1,5 @@
/Users/agra/projects/sx/examples/57-err-dot-shorthand.sx:5:10: error: cannot infer enum type for '.Foo' — use an explicit type or assign to a typed variable
error: cannot infer enum type for '.Foo' — use an explicit type or assign to a typed variable
--> /Users/agra/projects/sx/examples/57-err-dot-shorthand.sx:5:10
|
5 | x := .Foo(1, 2);
| ^^^^

View File

@@ -1 +1,5 @@
/Users/agra/projects/sx/examples/59-err-bad-variant.sx:18:14: error: no variant 'Bogus' on type 'Shape'
error: no variant 'Bogus' on type 'Shape'
--> /Users/agra/projects/sx/examples/59-err-bad-variant.sx:18:14
|
18 | case .Bogus: (x) { print("bogus={}\n", x); }
| ^^^^^^

View File

@@ -1 +1,5 @@
/Users/agra/projects/sx/examples/ffi-jni-call-11-unsupported-return-diag.sx:24:14: error: JNI method 'Buf.get' returns 's8', which isn't supported by the JNI call-method lowering yet — only void/bool/s32/s64/f32/f64 and pointers are wired up
error: JNI method 'Buf.get' returns 's8', which isn't supported by the JNI call-method lowering yet — only void/bool/s32/s64/f32/f64 and pointers are wired up
--> /Users/agra/projects/sx/examples/ffi-jni-call-11-unsupported-return-diag.sx:24:14
|
24 | _ := b.get();
| ^^^^^

View File

@@ -1 +1,5 @@
/Users/agra/projects/sx/examples/ffi-objc-dsl-04-mismatch.sx:18:14: error: Obj-C selector for 'SxProbeMismatch.something_extra' has 2 keyword(s) but the call passes 1 argument(s); split the sx method name on '_' so it produces exactly 1 keyword(s), or override with `#selector("...")`
error: Obj-C selector for 'SxProbeMismatch.something_extra' has 2 keyword(s) but the call passes 1 argument(s); split the sx method name on '_' so it produces exactly 1 keyword(s), or override with `#selector("...")`
--> /Users/agra/projects/sx/examples/ffi-objc-dsl-04-mismatch.sx:18:14
|
18 | n := inst.something_extra(7);
| ^^^^^^^^^^^^^^^^^^^^