From e347f59e504a6600c17a2ea3132cb82aaf8fcef7 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 28 May 2026 19:37:00 +0300 Subject: [PATCH] lang F0.1: extractContext utility for diagnostic renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/errors.test.zig | 109 ++++++++++++++++++++++++++++++++++++++++++++ src/errors.zig | 66 +++++++++++++++++++++++++++ src/root.zig | 1 + 3 files changed, 176 insertions(+) create mode 100644 src/errors.test.zig diff --git a/src/errors.test.zig b/src/errors.test.zig new file mode 100644 index 0000000..49bef8e --- /dev/null +++ b/src/errors.test.zig @@ -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); +} diff --git a/src/errors.zig b/src/errors.zig index f24a9a4..391eebc 100644 --- a/src/errors.zig +++ b/src/errors.zig @@ -27,6 +27,72 @@ pub const SourceLoc = struct { } }; +pub const LineInfo = struct { + line_num: u32, + text: []const u8, +}; + +pub const ContextLines = struct { + lines: []LineInfo, + start_col: u32, + end_col: u32, + + pub fn deinit(self: *ContextLines, allocator: std.mem.Allocator) void { + allocator.free(self.lines); + } +}; + +pub fn extractContext( + allocator: std.mem.Allocator, + source: []const u8, + span: Span, +) !ContextLines { + const source_len: u32 = @intCast(source.len); + const start = @min(span.start, source_len); + const end = @max(start, @min(span.end, source_len)); + + var line_num: u32 = 1; + var line_start: u32 = 0; + var i: u32 = 0; + while (i < start) : (i += 1) { + if (source[i] == '\n') { + line_num += 1; + line_start = i + 1; + } + } + + const start_col = (start - line_start) + 1; + + var lines: std.ArrayList(LineInfo) = .empty; + defer lines.deinit(allocator); + + var cur_line_num = line_num; + var cur_line_start = line_start; + + while (true) { + var cur_line_end = cur_line_start; + while (cur_line_end < source_len and source[cur_line_end] != '\n') : (cur_line_end += 1) {} + + try lines.append(allocator, .{ + .line_num = cur_line_num, + .text = source[cur_line_start..cur_line_end], + }); + + if (end <= cur_line_end) { + const end_col = (end - cur_line_start) + 1; + const owned = try lines.toOwnedSlice(allocator); + return ContextLines{ + .lines = owned, + .start_col = start_col, + .end_col = end_col, + }; + } + + cur_line_num += 1; + cur_line_start = cur_line_end + 1; + } +} + pub const Diagnostic = struct { level: Level, message: []const u8, diff --git a/src/root.zig b/src/root.zig index 9db1619..fbef98f 100644 --- a/src/root.zig +++ b/src/root.zig @@ -7,6 +7,7 @@ pub const types = @import("types.zig"); pub const target = @import("target.zig"); pub const builtins = @import("builtins.zig"); pub const errors = @import("errors.zig"); +pub const errors_tests = @import("errors.test.zig"); pub const sema = @import("sema.zig"); pub const imports = @import("imports.zig"); pub const core = @import("core.zig");