lang F0.1: extractContext utility for diagnostic renderer

Adds LineInfo, ContextLines, and extractContext(allocator, source, span) to
errors.zig — a pure utility that returns the source lines covered by a span
plus columns for caret rendering. Prereq for F0.2's new render path which
will produce Rust-style multi-line diagnostics with code excerpts.

8 unit tests cover the boundary cases: single-line span, multi-line spans
(1 and 2 newlines crossed), span on an empty line, span at end-of-file
without trailing newline, empty source, and offsets beyond source.len
(clamping).

No render surface change yet; F0.2 wires this into a new render mode kept
behind a RenderStyle flag so old gcc-style output remains available during
the transition.
This commit is contained in:
agra
2026-05-28 19:37:00 +03:00
parent 29bd182f3f
commit e347f59e50
3 changed files with 176 additions and 0 deletions

109
src/errors.test.zig Normal file
View File

@@ -0,0 +1,109 @@
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);
}