From 02ea4db25f9802e76112f3b4e69aa75e5a9464dc Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 15 Sep 2026 18:41:15 -0300 Subject: Default location results to stacked addresses and source previews --- docs/config.md | 7 ++- src/dump.zig | 2 +- src/locations.zig | 121 +++++++++++++++++++++++++------------ src/locations_config.zig | 8 ++- src/panes.zig | 69 +++++++++++++++++++-- src/pardes.zig | 2 +- src/syntax.zig | 111 ++++++++++++++++++++++------------ test/output.zig | 154 ++++++++++++++++++++++++++++++++++++++++++++--- test/panes.zig | 12 +++- test/pdf.zig | 17 +++++- 10 files changed, 406 insertions(+), 97 deletions(-) diff --git a/docs/config.md b/docs/config.md index 5b5641ba..6f3273b9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -150,7 +150,7 @@ results in an output pane. Execute the printed line to apply it again, or supply just the fields to change: ```text -LocationsConfig context:5 tscontext:on tslocations:off +LocationsConfig context:5 tscontext:on tslocations:off layout:stacked ``` - `context` is the number of source lines above and below each match (default @@ -163,8 +163,11 @@ LocationsConfig context:5 tscontext:on tslocations:off (default `on`); continuation lines keep the same alignment without repeating the location. Declaration headers use the same muted color as locations, with or without their locations visible. +- `layout:stacked` (default) puts each location on its own line, followed by its + source preview. Hidden context locations do not add an empty address line. + Use `layout:inline` to put locations beside the source instead. -Result locations share padding in groups of eight matches, so a long path only +In inline layout, result locations share padding in groups of eight matches, so a long path only widens its own group. Context does not count toward the eight; at group boundaries, it aligns with the nearer match (ties stay with the preceding group). Source indentation is preserved. All visible locations start flush left; `n` and `N` diff --git a/src/dump.zig b/src/dump.zig index 0c538a1a..89abd995 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -75,7 +75,7 @@ pub const Terminal = struct { }; pub const LocationRow = struct { - kind: enum { match, context }, + kind: enum { match, context, preview }, declaration: bool = false, path: []const u8, line: usize, diff --git a/src/locations.zig b/src/locations.zig index 0f580e34..97caf1d9 100644 --- a/src/locations.zig +++ b/src/locations.zig @@ -15,7 +15,7 @@ pub fn isContextLine(line: []const u8) bool { } pub const Row = struct { - kind: enum { match, context }, + kind: enum { match, context, preview }, declaration: bool = false, path: []u8, at: look.Spot, @@ -44,15 +44,16 @@ pub fn equalRows(a: []const Row, b: []const Row) bool { /// Compare native producer rows with an already formatted, context-free view. /// Identical refreshes retain allocations, syntax and selection state. -pub fn sameInput(input: []const u8, rendered: []const u8, rows: []const Row) bool { +pub fn sameInput(input: []const u8, rendered: []const u8, rows: []const Row, layout: Config.Layout) bool { if (rows.len == 0) return std.mem.eql(u8, input, rendered); - if (std.mem.endsWith(u8, input, "\n") != std.mem.endsWith(u8, rendered, "\n")) return false; var raw_lines = std.mem.splitScalar(u8, input, '\n'); var shown_lines = std.mem.splitScalar(u8, rendered, '\n'); var width: usize = 0; - for (rows, 0..) |row, index| { + var index: usize = 0; + while (index < rows.len) { + const row = rows[index]; if (row.kind != .match) return false; - if (index % alignment_entries == 0) { + if (layout == .@"inline" and index % alignment_entries == 0) { width = 0; var chunk = raw_lines; for (0..alignment_entries) |_| { @@ -64,9 +65,19 @@ pub fn sameInput(input: []const u8, rendered: []const u8, rows: []const Row) boo const raw = raw_lines.next() orelse return false; const shown = shown_lines.next() orelse return false; const target = parseRaw(raw); - if (target.end != row.location_end or row.code_start > shown.len or - !std.mem.eql(u8, raw[0..target.end], shown[0..row.location_end]) or - !std.mem.eql(u8, raw[target.code_start..], shown[row.code_start..])) return false; + if (target.end != row.location_end or row.location_end > shown.len or row.code_start > shown.len or + !std.mem.eql(u8, raw[0..target.end], shown[0..row.location_end])) return false; + if (layout == .stacked) { + if (shown.len != row.location_end or row.code_start != shown.len or index + 1 >= rows.len) return false; + const preview = rows[index + 1]; + if (preview.kind != .preview or preview.location_end != 0 or preview.code_start != 0 or + !std.meta.eql(row.at, preview.at) or !std.mem.eql(u8, row.path, preview.path)) return false; + const code = shown_lines.next() orelse return false; + if (!std.mem.eql(u8, raw[target.code_start..], code)) return false; + index += 2; + continue; + } + if (!std.mem.eql(u8, raw[target.code_start..], shown[row.code_start..])) return false; // A restored result may still have the old pane-wide padding. Do not // let the identical-input shortcut retain that layout when rerun. if (row.code_start <= row.location_end) return false; @@ -74,9 +85,14 @@ pub fn sameInput(input: []const u8, rendered: []const u8, rows: []const Row) boo if (padding.len != width - pardes.panes.File.displayWidth(raw[0..target.end]) + 1 or padding[padding.len - 1] != '\t') return false; for (padding[0 .. padding.len - 1]) |byte| if (byte != ' ') return false; + index += 1; } - if (raw_lines.next()) |tail| if (tail.len != 0) return false; - return raw_lines.next() == null; + const raw_tail = raw_lines.next(); + const shown_tail = shown_lines.next(); + if ((raw_tail == null) != (shown_tail == null)) return false; + if (raw_tail) |tail| if (tail.len != 0) return false; + if (shown_tail) |tail| if (tail.len != 0) return false; + return raw_lines.next() == null and shown_lines.next() == null; } pub const Parsed = struct { @@ -246,44 +262,73 @@ pub fn format(p: *pardes.Pardes, dir: []const u8, input: []const u8, anchor: ?us rows.deinit(p.gpa); } var mapped = anchor; - first = 0; - while (first < pending.items.len) { - const end = alignmentEnd(pending.items, first); - var width: usize = 0; - for (pending.items[first..end]) |row| width = @max(width, labelWidth(row)); - for (pending.items[first..end], first..) |row, index| { - const before = out.written().len; + if (p.locations_config.layout == .stacked) { + for (pending.items, 0..) |row, index| { const context = row.original == null; - try out.writer.writeAll(row.label); - const location_end = if (row.hidden) 0 else out.written().len - before; - if (row.depth > 0) { - try out.writer.writeByte(' '); - try out.writer.splatByteAll('*', row.depth); - } - try out.writer.splatByteAll(' ', width - labelWidth(row)); - try out.writer.writeByte('\t'); - const code_start = out.written().len - before; - try out.writer.writeAll(row.code); - if (row.declaration and index + 1 < pending.items.len) { - const next = pending.items[index + 1]; - if (std.mem.eql(u8, row.path, next.path) and next.at.line -| row.at.line > 1) - try out.writer.writeAll(" ..."); + if (anchor != null and row.original == anchor) mapped = rows.items.len; + if (!row.hidden) { + const before = out.written().len; + try out.writer.writeAll(row.label); + if (row.depth > 0) { + try out.writer.writeByte(' '); + try out.writer.splatByteAll('*', row.depth); + } + try appendMetadata(p.gpa, &rows, row, if (context) .context else .match, row.label.len, out.written().len - before, false); + try out.writer.writeByte('\n'); } + try writeCode(&out.writer, pending.items, index); + try appendMetadata(p.gpa, &rows, row, if (context) .context else .preview, 0, 0, true); if (index + 1 < pending.items.len or std.mem.endsWith(u8, input, "\n")) try out.writer.writeByte('\n'); - const owned_path = try p.gpa.dupe(u8, row.path); - errdefer p.gpa.free(owned_path); - const owned_colors = try p.gpa.dupe(u8, row.colors); - errdefer p.gpa.free(owned_colors); - try rows.append(p.gpa, .{ .kind = if (context) .context else .match, .declaration = row.declaration, .path = owned_path, .at = row.at, .location_end = location_end, .code_start = code_start, .colors = owned_colors }); - if (anchor != null and row.original == anchor) mapped = index; } - first = end; + } else { + first = 0; + while (first < pending.items.len) { + const end = alignmentEnd(pending.items, first); + var width: usize = 0; + for (pending.items[first..end]) |row| width = @max(width, labelWidth(row)); + for (pending.items[first..end], first..) |row, index| { + const before = out.written().len; + const context = row.original == null; + try out.writer.writeAll(row.label); + const location_end = if (row.hidden) 0 else out.written().len - before; + if (row.depth > 0) { + try out.writer.writeByte(' '); + try out.writer.splatByteAll('*', row.depth); + } + try out.writer.splatByteAll(' ', width - labelWidth(row)); + try out.writer.writeByte('\t'); + const code_start = out.written().len - before; + try writeCode(&out.writer, pending.items, index); + if (index + 1 < pending.items.len or std.mem.endsWith(u8, input, "\n")) try out.writer.writeByte('\n'); + try appendMetadata(p.gpa, &rows, row, if (context) .context else .match, location_end, code_start, true); + if (anchor != null and row.original == anchor) mapped = index; + } + first = end; + } } const owned_rows = try rows.toOwnedSlice(p.gpa); errdefer freeRows(p.gpa, owned_rows); return .{ .content = try out.toOwnedSlice(), .rows = owned_rows, .anchor = mapped }; } +fn writeCode(writer: *std.Io.Writer, rows: []const Pending, index: usize) !void { + const row = rows[index]; + try writer.writeAll(row.code); + if (row.declaration and index + 1 < rows.len) { + const next = rows[index + 1]; + if (std.mem.eql(u8, row.path, next.path) and next.at.line -| row.at.line > 1) + try writer.writeAll(" ..."); + } +} + +fn appendMetadata(gpa: std.mem.Allocator, rows: *std.ArrayList(Row), pending: Pending, kind: @FieldType(Row, "kind"), location_end: usize, code_start: usize, source: bool) !void { + const path = try gpa.dupe(u8, pending.path); + errdefer gpa.free(path); + const colors = try gpa.dupe(u8, if (source) pending.colors else &.{}); + errdefer gpa.free(colors); + try rows.append(gpa, .{ .kind = kind, .declaration = pending.declaration, .path = path, .at = pending.at, .location_end = location_end, .code_start = code_start, .colors = colors }); +} + const alignment_entries = 8; /// Keep neighboring context with the nearer match at a chunk boundary. Shared diff --git a/src/locations_config.zig b/src/locations_config.zig index ed67f990..6b23575a 100644 --- a/src/locations_config.zig +++ b/src/locations_config.zig @@ -1,9 +1,11 @@ const std = @import("std"); pub const Config = struct { + pub const Layout = enum { @"inline", stacked }; context: u16 = 0, tscontext: bool = false, tslocations: bool = true, + layout: Layout = .stacked, /// Apply only supplied fields. A malformed token rejects the whole update. /// Repeating a field uses its last supplied value. @@ -20,6 +22,7 @@ pub const Config = struct { @field(result, field.name) = switch (@typeInfo(field.type)) { .bool => if (std.mem.eql(u8, value, "on")) true else if (std.mem.eql(u8, value, "off")) false else return error.ExpectedOnOrOff, .int => std.fmt.parseInt(field.type, value, 10) catch return error.InvalidContext, + .@"enum" => std.meta.stringToEnum(field.type, value) orelse return error.InvalidLayout, else => @compileError("unsupported location setting type"), }; found = true; @@ -38,6 +41,7 @@ pub const Config = struct { switch (@typeInfo(field.type)) { .bool => try writer.writeAll(if (value) "on" else "off"), .int => try writer.print("{d}", .{value}), + .@"enum" => try writer.writeAll(@tagName(value)), else => @compileError("unsupported location setting type"), } } @@ -52,10 +56,11 @@ test "LocationsConfig reflection round trip and partial updates" { var out: std.Io.Writer.Allocating = .init(std.testing.allocator); defer out.deinit(); try config.write(&out.writer); - try std.testing.expectEqualStrings("context:5 tscontext:on tslocations:off", out.written()); + try std.testing.expectEqualStrings("context:5 tscontext:on tslocations:off layout:stacked", out.written()); try std.testing.expectEqual(config, try (Config{}).parse(out.written())); try std.testing.expectEqual(config, try config.parse(" \n\t")); try std.testing.expectEqual(@as(u16, 2), (try config.parse("context:1 context:2")).context); + try std.testing.expectEqual(Config.Layout.@"inline", (try config.parse("layout:inline")).layout); } test "LocationsConfig rejects invalid updates atomically" { @@ -63,6 +68,7 @@ test "LocationsConfig rejects invalid updates atomically" { try std.testing.expectError(error.UnknownLocationSetting, config.parse("context:5 unknown:on")); try std.testing.expectError(error.ExpectedOnOrOff, config.parse("context:5 tscontext:true")); try std.testing.expectError(error.ExpectedKeyValue, config.parse("context")); + try std.testing.expectError(error.InvalidLayout, config.parse("context:5 layout:sideways")); for ([_][]const u8{ "context:", "context:-1", "context:65536", "context:five" }) |input| try std.testing.expectError(error.InvalidContext, config.parse(input)); try std.testing.expectEqual(@as(u16, 3), config.context); diff --git a/src/panes.zig b/src/panes.zig index 7855866d..59993148 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -1082,6 +1082,11 @@ pub const File = struct { return saved; } + fn sameSavedLocation(a: dump.LocationRow, b: dump.LocationRow) bool { + return std.mem.eql(u8, a.path, b.path) and a.line == b.line and a.col == b.col and + a.end_line == b.end_line and a.end_col == b.end_col; + } + fn restoreLocationRows(gpa: std.mem.Allocator, content: []const u8, saved: []const dump.LocationRow) ![]locations.Row { if (saved.len == 0) return &.{}; if (saved.len > std.mem.count(u8, content, "\n") + 1) return error.InvalidLocationRows; @@ -1095,16 +1100,33 @@ pub const File = struct { gpa.free(rows); } var lines = std.mem.splitScalar(u8, content, '\n'); - for (saved, rows) |row, *out| { + var previous_line: []const u8 = ""; + for (saved, rows, 0..) |row, *out, index| { const line = lines.next() orelse return error.InvalidLocationRows; if (row.path.len == 0 or row.path.len >= 4096 or - row.location_end > row.code_start or row.code_start == 0 or row.code_start > line.len or - line[row.code_start - 1] != '\t' or + row.location_end > row.code_start or row.code_start > line.len or (row.kind == .match and row.location_end == 0) or (row.declaration and row.kind != .context) or row.line > std.math.maxInt(i32) or row.col > std.math.maxInt(i32) or row.end_line > std.math.maxInt(i32) or row.end_col > std.math.maxInt(i32)) return error.InvalidLocationRows; + const inline_row = row.code_start > row.location_end and row.code_start > 0 and line[row.code_start - 1] == '\t'; + const address_row = !inline_row and row.location_end > 0 and row.code_start == line.len; + const source_row = row.location_end == 0 and row.code_start == 0; + if (!inline_row and !address_row and !source_row) return error.InvalidLocationRows; + if (row.kind == .preview) { + if (!source_row or index == 0) return error.InvalidLocationRows; + const previous = saved[index - 1]; + if (previous.kind != .match or previous.location_end == 0 or previous.code_start != previous_line.len or previous_line[previous_line.len - 1] == '\t' or + !sameSavedLocation(previous, row)) return error.InvalidLocationRows; + } + if (address_row) { + if (index + 1 >= saved.len) return error.InvalidLocationRows; + const following = saved[index + 1]; + const expected: @TypeOf(row.kind) = if (row.kind == .match) .preview else .context; + if (following.kind != expected or following.code_start != 0 or following.location_end != 0 or + following.declaration != row.declaration or !sameSavedLocation(row, following)) return error.InvalidLocationRows; + } const code_len = line.len - row.code_start; if (row.colors_b64.len > std.base64.standard.Encoder.calcSize(code_len)) return error.InvalidLocationRows; const colors = try dump.decodeBytes(gpa, row.colors_b64); @@ -1122,6 +1144,7 @@ pub const File = struct { .colors = colors, }; initialized += 1; + previous_line = line; } if (lines.next()) |remaining| if (remaining.len != 0 or lines.next() != null) return error.InvalidLocationRows; return rows; @@ -1149,6 +1172,32 @@ pub const File = struct { try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, "| \tx\n", &.{row})); } + test "stacked location metadata requires matching adjacent preview ownership" { + const gpa = std.testing.allocator; + const header = "x.c:2:1-3"; + const content = header ++ "\nfoo\n"; + const match: dump.LocationRow = .{ .kind = .match, .path = "x.c", .line = 2, .col = 1, .end_line = 2, .end_col = 3, .location_end = header.len, .code_start = header.len }; + const preview: dump.LocationRow = .{ .kind = .preview, .path = "x.c", .line = 2, .col = 1, .end_line = 2, .end_col = 3, .location_end = 0, .code_start = 0 }; + const restored = try restoreLocationRows(gpa, content, &.{ match, preview }); + defer locations.freeRows(gpa, restored); + try std.testing.expectEqual(.preview, restored[1].kind); + try std.testing.expectEqual(restored[0].at, restored[1].at); + try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, "foo\n", &.{preview})); + try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, header ++ "\n", &.{match})); + var inline_match = match; + inline_match.code_start += 1; + try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, header ++ "\t\nfoo\n", &.{ inline_match, preview })); + var wrong = preview; + wrong.path = "other.c"; + try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, content, &.{ match, wrong })); + wrong = preview; + wrong.line = 3; + try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, content, &.{ match, wrong })); + wrong = preview; + wrong.code_start = 1; + try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, content, &.{ match, wrong })); + } + pub fn graphemeDisplayWidth(grapheme: []const u8) usize { if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width; if (grapheme.len == 1 and grapheme[0] >= 0x20 and grapheme[0] < 0x7f) return 1; @@ -2258,6 +2307,18 @@ pub const Output = struct { } } + test "stacked empty previews distinguish separators from trailing newlines" { + var path = [_]u8{ 'x', '.', 'c' }; + const rows = [_]locations.Row{ + .{ .kind = .match, .path = &path, .at = .{ .line = 1, .col = 1 }, .location_end = 7, .code_start = 7 }, + .{ .kind = .preview, .path = &path, .at = .{ .line = 1, .col = 1 }, .location_end = 0, .code_start = 0 }, + }; + try std.testing.expect(locations.sameInput("x.c:1:1 ", "x.c:1:1\n", &rows, .stacked)); + try std.testing.expect(locations.sameInput("x.c:1:1 \n", "x.c:1:1\n\n", &rows, .stacked)); + try std.testing.expect(!locations.sameInput("x.c:1:1 ", "x.c:1:1\n\n", &rows, .stacked)); + try std.testing.expect(!locations.sameInput("x.c:1:1 \n", "x.c:1:1\n", &rows, .stacked)); + } + test "plain output does not opt into location decoration" { const report = rowDecoration(.{ .from = .{ .cmd = .LocationsConfig } }, "LocationsConfig context:5 tscontext:on"); try std.testing.expectEqualDeep(RowDecoration{}, report); @@ -2527,7 +2588,7 @@ pub const Output = struct { if (!std.meta.eql(output.from, from)) continue; if (by_arg and !std.mem.eql(u8, output.arg(), arg)) continue; if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue; - if (!locations.sameInput(content, rf.content, rf.location_rows)) continue; + if (!locations.sameInput(content, rf.content, rf.location_rows, p.locations_config.layout)) continue; try setArg(output, arg); p.gpa.free(content); p.active = id; diff --git a/src/pardes.zig b/src/pardes.zig index d115bf2d..2729ef93 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1121,7 +1121,7 @@ test "LocationsConfig command reports partial updates and survives restore" { const out = file.output orelse continue; if (!std.meta.eql(out.from, panes.Output.Origin{ .cmd = .LocationsConfig })) continue; reports += 1; - try std.testing.expectEqualStrings("LocationsConfig context:5 tscontext:on tslocations:off\n", file.content); + try std.testing.expectEqualStrings("LocationsConfig context:5 tscontext:on tslocations:off layout:stacked\n", file.content); } try std.testing.expectEqual(@as(usize, 1), reports); try p.dumpState(); diff --git a/src/syntax.zig b/src/syntax.zig index bd6a64a0..9e31669f 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -630,48 +630,58 @@ pub fn highlightLocationRows(gpa: std.mem.Allocator, content: []const u8, rows: defer gpa.free(code); const colors = try gpa.alloc(u8, content.len); defer gpa.free(colors); - var painted = false; - var row_index: usize = 0; + const SourceRow = struct { index: usize, start: usize, len: usize }; + const sources = try gpa.alloc(SourceRow, rows.len); + defer gpa.free(sources); + var source_count: usize = 0; var offset: usize = 0; - while (row_index < rows.len and offset < content.len) { - const first = row_index; - const group_start = offset; - const selected = (forExt(std.fs.path.extension(rows[first].path)) catch null); - var code_len: usize = 0; - while (row_index < rows.len and offset < content.len) { - const row = rows[row_index]; - if (row_index > first and (!std.mem.eql(u8, row.path, rows[first].path) or - row.at.line != rows[row_index - 1].at.line +| 1)) break; - const end = std.mem.indexOfScalarPos(u8, content, offset, '\n') orelse content.len; - const source_start = offset + @min(row.code_start, end - offset); - if (row_index > first) { - code[code_len] = '\n'; - code_len += 1; - } - @memcpy(code[code_len..][0 .. end - source_start], content[source_start..end]); - code_len += end - source_start; - offset = @min(end + 1, content.len); - row_index += 1; + for (rows, 0..) |row, index| { + if (offset >= content.len) break; + const end = std.mem.indexOfScalarPos(u8, content, offset, '\n') orelse content.len; + const source_start = offset + @min(row.code_start, end - offset); + var address_only = false; + if (comptime @hasField(@TypeOf(row), "location_end")) { + address_only = row.location_end > 0 and source_start == end; } - const language = selected orelse continue; - if (offset > 0 and content[offset - 1] == '\n') { - code[code_len] = '\n'; - code_len += 1; + if (!address_only) { + sources[source_count] = .{ .index = index, .start = source_start, .len = end - source_start }; + source_count += 1; } - if (code_len == 0) continue; - @memset(colors[0..code_len], 0); - paint(colors[0..code_len], code[0..code_len], language); - painted = true; - var target = group_start; - var source_offset: usize = 0; - for (rows[first..row_index]) |row| { - const end = std.mem.indexOfScalarPos(u8, content, target, '\n') orelse content.len; - const source_start = target + @min(row.code_start, end - target); - const len = end - source_start; - @memcpy(styles[source_start..end], colors[source_offset..][0..len]); - source_offset += len + 1; - target = @min(end + 1, content.len); + offset = @min(end + 1, content.len); + } + var painted = false; + var first: usize = 0; + while (first < source_count) { + const first_row = rows[sources[first].index]; + var end = first + 1; + while (end < source_count) : (end += 1) { + const row = rows[sources[end].index]; + const previous = rows[sources[end - 1].index]; + if (!std.mem.eql(u8, row.path, first_row.path) or row.at.line != previous.at.line +| 1) break; } + const language = (forExt(std.fs.path.extension(first_row.path)) catch null); + if (language) |selected| { + var code_len: usize = 0; + for (sources[first..end]) |source| { + @memcpy(code[code_len..][0..source.len], content[source.start..][0..source.len]); + code_len += source.len; + if (code_len < code.len) { + code[code_len] = '\n'; + code_len += 1; + } + } + if (code_len > 0) { + @memset(colors[0..code_len], 0); + paint(colors[0..code_len], code[0..code_len], selected); + painted = true; + var at: usize = 0; + for (sources[first..end]) |source| { + @memcpy(styles[source.start..][0..source.len], colors[at..][0..source.len]); + at += source.len + 1; + } + } + } + first = end; } // A shown context range can begin inside a comment/string whose opener was // omitted. Formatter snapshots from the complete source take precedence, @@ -1244,3 +1254,28 @@ test "syntax declaration locations clear snapshots without muting ordinary conte for (styles[3..][0..header.len]) |style| try std.testing.expectEqual(@as(u8, 0), style); try std.testing.expectEqual(@intFromEnum(Syn.number), styles[std.mem.indexOf(u8, content, "42").?]); } + +test "syntax stacked addresses do not split multiline source groups" { + if (!enabled or (!minimal_grammars and !full_grammars)) return; + const gpa = std.testing.allocator; + start(gpa); + defer stop(); + const Row = struct { path: []const u8 = "a.c", code_start: usize, location_end: usize, at: look.Spot }; + const content = "a.c:1:1\n/* open\na.c:2:1\nint value = 42;\na.c:3:1\n*/ int other = 7;\n"; + const rows = [_]Row{ + .{ .code_start = 7, .location_end = 7, .at = .{ .line = 1 } }, + .{ .code_start = 0, .location_end = 0, .at = .{ .line = 1 } }, + .{ .code_start = 7, .location_end = 7, .at = .{ .line = 2 } }, + .{ .code_start = 0, .location_end = 0, .at = .{ .line = 2 } }, + .{ .code_start = 7, .location_end = 7, .at = .{ .line = 3 } }, + .{ .code_start = 0, .location_end = 0, .at = .{ .line = 3 } }, + }; + const styles = try highlightLocationRows(gpa, content, &rows); + defer gpa.free(styles); + try std.testing.expectEqual(@intFromEnum(Syn.comment), styles[std.mem.indexOf(u8, content, "42").?]); + try std.testing.expectEqual(@intFromEnum(Syn.number), styles[std.mem.indexOf(u8, content, "7;").?]); + for ([_][]const u8{ "a.c:1:1", "a.c:2:1", "a.c:3:1" }) |label| { + const at = std.mem.indexOf(u8, content, label).?; + for (styles[at..][0..label.len]) |style| try std.testing.expectEqual(@intFromEnum(Syn.none), style); + } +} diff --git a/test/output.zig b/test/output.zig index c162c1d1..c738a661 100644 --- a/test/output.zig +++ b/test/output.zig @@ -92,6 +92,7 @@ test "output callers opt into location highlighting instead of matching prose co for ([_]panes.Output.Origin{ .{ .cmd = .LocationsConfig }, .{ .cmd = .Config }, .{ .query = .hover } }) |origin| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 140, .rows = 30 }); defer p.deinit(); + p.locations_config.layout = .@"inline"; _ = try p.setTestFile("ordinary\n"); p.presentation.enabled = false; if (origin == .cmd and origin.cmd == .LocationsConfig) { @@ -122,6 +123,7 @@ test "location producers retain rendered location match and diagnostic styles" { for ([_]panes.Output.Origin{ .search, .{ .cmd = .Grep }, .{ .query = .references }, .{ .query = .diagnostics } }) |origin| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 140, .rows = 30 }); defer p.deinit(); + p.locations_config.layout = .@"inline"; _ = try p.setTestFile("ordinary\n"); p.presentation.enabled = false; const diagnostic = origin == .query and origin.query == .diagnostics; @@ -157,6 +159,7 @@ test "location results sort filenames and numeric positions while remapping thei for ([_]panes.Output.Origin{ .search, .{ .cmd = .Grep }, .{ .query = .references }, .{ .query = .diagnostics } }) |origin| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = try p.setTestFile("notes\n"); try panes.Output.fillResults(p, 0, "/tmp", origin, "", try p.gpa.dupe(u8, before), 2); const results = p.panes[source.search_pane.?].?; @@ -170,6 +173,7 @@ test "ranked choices and prose retain their supplied order" { for ([_]pardes.lsp.Kind{ .completion, .hover, .code_action }) |kind| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); + p.locations_config.layout = .@"inline"; _ = try p.setTestFile("notes\n"); try panes.Output.fillResults(p, 0, "/tmp", .{ .query = kind }, "", try p.gpa.dupe(u8, text), null); const result = for (p.panes) |slot| { @@ -192,6 +196,7 @@ test "reference Look selects the next sorted row from the captured request and k const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/m.zig", .{tmp.sub_path}); const p = try Pardes.init(std.testing.allocator, .{ .file = path, .cols = 80, .rows = 24 }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = p.panes[0].?; const response = "m.zig:5:5-8 later\nz.zig:1:1 last-file\nm.zig:1:7-10 earlier\nm.zig:3:5-8 current\na.zig:1:1 first-file\nm.zig:4:5-8 next\n"; const sorted = "a.zig:1:1 first-file\nm.zig:1:7-10 earlier\nm.zig:3:5-8 current\nm.zig:4:5-8 next\nm.zig:5:5-8 later\nz.zig:1:1 last-file\n"; @@ -254,6 +259,7 @@ test "location sorting leaves mixed prose alone and preserves newline shape" { for (cases) |case| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = try p.setTestFile("notes\n"); try panes.Output.fillResults(p, 0, "/tmp", .search, "", try p.gpa.dupe(u8, case.before), 1); try expectResultRows(case.after, &p.panes[source.search_pane.?].?.file.?); @@ -301,6 +307,7 @@ test "result locations stop before preview text and retain full addresses" { test "reordered identical results preserve the selected row and warm line index" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = try p.setTestFile("notes\n"); const sorted = "a.zig:2:1 first\na.zig:10:1 second\n"; try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "", try p.gpa.dupe(u8, sorted), null); @@ -328,6 +335,7 @@ test "reordered identical results preserve the selected row and warm line index" test "result sorting allocation failures preserve the previous answer and navigation" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = try p.setTestFile("notes\n"); try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "before", try p.gpa.dupe(u8, "a.zig:1:1 keep\n"), 0); const id = source.search_pane.?; @@ -360,6 +368,7 @@ test "result sorting allocation failures preserve the previous answer and naviga test "Find sorts whole filenames and next location uses numeric columns" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = try p.setTestFile(""); try panes.Output.fillResults(p, 0, "/tmp", .{ .cmd = .Find }, "", try p.gpa.dupe(u8, "x:2\nx:10\n"), 0); const result = p.panes[source.search_pane.?].?; @@ -374,6 +383,7 @@ test "Find sorts whole filenames and next location uses numeric columns" { test "result stepping reaches an unterminated last row" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = try p.setTestFile("one\ntwo\n"); const rows = try std.fmt.allocPrint(p.gpa, "{s}:1:1 first\n{s}:2:1 last", .{ source.file.?.path, source.file.?.path }); try panes.Output.fillResults(p, 0, "/tmp", .search, "", rows, 0); @@ -604,6 +614,7 @@ test "EffectCode lists readable virtual sources used by this backend" { test "every enabled setting builtin mutates the State Config reports" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); + p.locations_config.layout = .@"inline"; try std.testing.expectEqual(config.gui_tagline_font_percent, p.settings.font.tagline_percent); var font_arena: std.heap.ArenaAllocator = .init(std.testing.allocator); @@ -730,6 +741,7 @@ test "n/N skips formatted context and selects comment match locations" { for ([_]bool{ false, true }) |tslocations| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const text = "const Box = struct {\n" ++ " pub fn run() void {\n" ++ " // /tmp neighboring path\n" ++ @@ -742,7 +754,7 @@ test "n/N skips formatted context and selects comment match locations" { p.gpa.free(source.file.?.path); source.file.?.path = try p.gpa.dupe(u8, "/look-walk-context.zig"); p.presentation.enabled = false; - p.locations_config = .{ .context = 1, .tscontext = true, .tslocations = tslocations }; + p.locations_config = .{ .layout = .@"inline", .context = 1, .tscontext = true, .tslocations = tslocations }; const path = source.file.?.path; const input = try std.fmt.allocPrint(p.gpa, "{s}:4:9 {s}\n{s}:6:9 {s}\n", .{ path, modal.lineSlice(text, 3), path, modal.lineSlice(text, 5), @@ -798,6 +810,7 @@ test "n/N selects one output location per row and opens nothing" { const paths = try projectPaths(&cwd_buf, &path_buf); const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot }); defer p.deinit(); + p.locations_config.layout = .@"inline"; p.update(.{ .resize = .{ .cols = 100, .rows = 40 } }); const rid = try walkFixture(p, p.active, paths.cwd, "one"); const results = p.panes[rid].?; @@ -837,6 +850,7 @@ test "n/N resumes the result output whose Look moved focus away" { const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/look-owner.txt", .{tmp.sub_path}); const p = try Pardes.init(std.testing.allocator, .{ .file = path, .cols = 80, .rows = 24 }); defer p.deinit(); + p.locations_config.layout = .@"inline"; try std.testing.expect(p.executeBuiltinLine(0, "Look target")); const rid = p.panes[0].?.search_pane orelse return error.MissingResults; @@ -889,6 +903,7 @@ test "N exactly reverses n across output panes and the ring seam" { const paths = try projectPaths(&cwd_buf, &path_buf); const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot }); defer p.deinit(); + p.locations_config.layout = .@"inline"; p.update(.{ .resize = .{ .cols = 100, .rows = 40 } }); const first = try walkFixture(p, p.active, paths.cwd, "one"); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); @@ -995,6 +1010,7 @@ test "identical result refresh keeps warm content caches without allocation" { var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = try p.setTestFile("notes\n"); p.settings.colors = true; const text = "a.zig:1:1 pub fn first() void {}\na.zig:2:1 if (true) return;\n"; @@ -1088,6 +1104,7 @@ test "rejected result refresh frees incoming text and leaves the existing output var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = try p.setTestFile("notes\n"); try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "before", try p.gpa.dupe(u8, "keep\n"), 1); const id = source.search_pane.?; @@ -1152,8 +1169,9 @@ test "result alignment chunks keep seventeen matches independent and refresh sta for ([_]usize{ 7, 8 }) |long_at| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = try p.setTestFile("notes\n"); - p.locations_config = .{ .context = 0, .tscontext = false }; + p.locations_config = .{ .layout = .@"inline", .context = 0, .tscontext = false }; var input: std.ArrayList(u8) = .empty; defer input.deinit(p.gpa); var widths: [17]usize = undefined; @@ -1234,13 +1252,14 @@ test "result alignment chunks assign boundary context to nearest match with prev for ([_]panes.Output.Origin{ .search, .{ .query = .references } }) |origin| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); + p.locations_config.layout = .@"inline"; var source_text: std.ArrayList(u8) = .empty; defer source_text.deinit(p.gpa); for (0..14) |_| try source_text.appendSlice(p.gpa, "\t source();\n"); const source = try p.setTestFile(source_text.items); p.gpa.free(source.file.?.path); source.file.?.path = try p.gpa.dupe(u8, "/chunk-boundary-界.txt"); - p.locations_config = .{ .context = 2, .tscontext = false }; + p.locations_config = .{ .layout = .@"inline", .context = 2, .tscontext = false }; var input: std.ArrayList(u8) = .empty; defer input.deinit(p.gpa); for ([_]usize{ 1, 2, 3, 4, 5, 6, 7, 8, 12 }) |line| { @@ -1295,10 +1314,11 @@ test "declaration context ellipses mark only final header lines before omitted s for ([_]u8{ 0, 1 }) |neighbors| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = try p.setTestFile(text); p.gpa.free(source.file.?.path); source.file.?.path = try p.gpa.dupe(u8, "/ellipsis-context.zig"); - p.locations_config = .{ .context = neighbors, .tscontext = true, .tslocations = visible }; + p.locations_config = .{ .layout = .@"inline", .context = neighbors, .tscontext = true, .tslocations = visible }; const input = try std.fmt.allocPrint(p.gpa, "{s}:12:17 {s}\n{s}:16:9 {s}\n", .{ source.file.?.path, modal.lineSlice(text, 11), source.file.?.path, modal.lineSlice(text, 15), }); @@ -1344,10 +1364,11 @@ test "LSP occurrence tint follows the supplied source span through context" { for ([_]pardes.lsp.Kind{ .references, .definition }) |kind| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = try p.setTestFile(text); p.gpa.free(source.file.?.path); source.file.?.path = try p.gpa.dupe(u8, "/occurrence.zig"); - p.locations_config = .{ .context = 1, .tscontext = true }; + p.locations_config = .{ .layout = .@"inline", .context = 1, .tscontext = true }; p.presentation.enabled = false; var input: std.Io.Writer.Allocating = .init(p.gpa); defer input.deinit(); @@ -1400,10 +1421,11 @@ test "result context upper bound stops at earliest enclosing declaration" { for ([_]bool{ false, true }) |visible| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = try p.setTestFile(text); p.gpa.free(source.file.?.path); source.file.?.path = try p.gpa.dupe(u8, "/bounded-context.zig"); - p.locations_config = .{ .context = 100, .tscontext = tscontext, .tslocations = visible }; + p.locations_config = .{ .layout = .@"inline", .context = 100, .tscontext = tscontext, .tslocations = visible }; const input = try std.fmt.allocPrint(p.gpa, "{s}:8:13 {s}\n", .{ source.file.?.path, modal.lineSlice(text, 7) }); try panes.Output.fillResults(p, 0, "/", origin, "upper bound", input, 0); const file = &p.panes[source.search_pane.?].?.file.?; @@ -1431,11 +1453,12 @@ test "result context upper bound stops at earliest enclosing declaration" { for ([_][]const u8{ "/no-ancestor.zig", "/no-grammar.txt" }) |path| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const plain = "// first\n// second\n// matched\n// trailing"; const source = try p.setTestFile(plain); p.gpa.free(source.file.?.path); source.file.?.path = try p.gpa.dupe(u8, path); - p.locations_config = .{ .context = 100, .tscontext = true, .tslocations = false }; + p.locations_config = .{ .layout = .@"inline", .context = 100, .tscontext = true, .tslocations = false }; const input = try std.fmt.allocPrint(p.gpa, "{s}:3:4 // matched\n", .{path}); try panes.Output.fillResults(p, 0, "/", .search, "unbounded", input, 0); const file = &p.panes[source.search_pane.?].?.file.?; @@ -1445,3 +1468,120 @@ test "result context upper bound stops at earliest enclosing declaration" { try std.testing.expectEqual(@as(usize, 2), source.search_row.?); } } + +test "stacked search and LSP results retain source tint navigation and dump ownership" { + if (!pardes.syntax.enabled) return error.SkipZigTest; + const first = "\tfoo + 界 + foo;"; + const second = " return foo;"; + const source_text = "const Box = struct {\n fn run() void {\n" ++ first ++ "\n const neighbor = 42;\n" ++ second ++ "\n }\n};\n"; + for ([_]panes.Output.Origin{ .search, .{ .query = .references } }) |origin| { + for ([_]bool{ false, true }) |visible| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 50 }); + defer p.deinit(); + try std.testing.expectEqual(.stacked, p.locations_config.layout); + p.locations_config = .{ .context = 1, .tscontext = true, .tslocations = visible }; + p.presentation.enabled = false; + const source = try p.setTestFile(source_text); + p.gpa.free(source.file.?.path); + source.file.?.path = try p.gpa.dupe(u8, "/stacked-界.zig"); + var input: std.Io.Writer.Allocating = .init(p.gpa); + defer input.deinit(); + const first_col = std.mem.lastIndexOf(u8, first, "foo").?; + const second_col = std.mem.lastIndexOf(u8, second, "foo").?; + try pardes.lsp.sourceSpanRow(&input.writer, source.file.?.path, 2, first_col, 2, first_col + 3, first); + try pardes.lsp.sourceSpanRow(&input.writer, source.file.?.path, 4, second_col, 4, second_col + 3, second); + try panes.Output.fillResults(p, 0, "/", origin, "stacked", try p.gpa.dupe(u8, input.written()), 1); + const id = source.search_pane.?; + const pane = p.panes[id].?; + const file = &pane.file.?; + var matches: [2]usize = undefined; + var count: usize = 0; + var previews: usize = 0; + var context_addresses: usize = 0; + for (file.location_rows, 0..) |row, index| { + const line = modal.lineSlice(file.content, index); + switch (row.kind) { + .match => { + try std.testing.expectEqual(line.len, row.code_start); + try std.testing.expect(row.location_end > 0); + try std.testing.expect(!std.mem.containsAtLeast(u8, line, 1, "foo")); + matches[count] = index; + count += 1; + }, + .preview => { + try std.testing.expect(index > 0); + try std.testing.expectEqual(.match, file.location_rows[index - 1].kind); + try std.testing.expectEqual(file.location_rows[index - 1].at, row.at); + try std.testing.expectEqualStrings(file.location_rows[index - 1].path, row.path); + try std.testing.expectEqual(@as(usize, 0), row.location_end); + try std.testing.expectEqual(@as(usize, 0), row.code_start); + try std.testing.expectEqualStrings(if (previews == 0) first else second, line); + previews += 1; + }, + .context => { + if (row.location_end > 0) { + try std.testing.expect(visible and row.declaration); + try std.testing.expectEqual(line.len, row.code_start); + context_addresses += 1; + } else { + try std.testing.expectEqual(@as(usize, 0), row.code_start); + try std.testing.expectEqualStrings(modal.lineSlice(source_text, row.at.line - 1), line); + } + }, + } + } + try std.testing.expectEqual(@as(usize, 2), count); + try std.testing.expectEqual(count, previews); + try std.testing.expectEqual(@as(usize, if (visible) 2 else 0), context_addresses); + try std.testing.expectEqual(matches[1], source.search_row.?); + const original_content = try p.gpa.dupe(u8, file.content); + defer p.gpa.free(original_content); + try panes.Output.fillResults(p, 0, "/", origin, "stacked", try p.gpa.dupe(u8, input.written()), 0); + try std.testing.expectEqual(id, source.search_pane.?); + try std.testing.expectEqualStrings(original_content, file.content); + pardes.layout.compute(p); + var arena = std.heap.ArenaAllocator.init(p.gpa); + defer arena.deinit(); + const surface = try p.render(arena.allocator()); + const chrome = pardes.ChromeTheme.fromTheme(p.theme()); + var marked: usize = 0; + for (surface.cells) |cell| { + if (!std.meta.eql(cell.style.bg, pardes.Color{ .rgb = chrome.search_bg })) continue; + try std.testing.expect(cell.text[0] == 'f' or cell.text[0] == 'o'); + marked += 1; + } + try std.testing.expectEqual(@as(usize, 6), marked); + const number = std.mem.indexOf(u8, file.content, "42").?; + try std.testing.expectEqual(@intFromEnum(pardes.syntax.Syn.number), file.highlights[number]); + p.active = id; + pane.mode = .normal; + p.armLookWalk(id); + for ([_]struct { key: u21, match: usize }{ .{ .key = 'n', .match = 0 }, .{ .key = 'n', .match = 1 }, .{ .key = 'N', .match = 0 } }) |step| { + p.update(.{ .key = .{ .cp = step.key } }); + try std.testing.expectEqual(@as(i32, @intCast(matches[step.match])), pane.cur_row); + const row = file.location_rows[matches[step.match]]; + const line = modal.lineSlice(file.content, matches[step.match]); + try std.testing.expectEqualStrings(line[0..row.location_end], selectedOutputText(pane) orelse ""); + } + try p.dumpState(); + const restored = try Pardes.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); + defer restored.deinit(); + const restored_file = &restored.panes[id].?.file.?; + try std.testing.expectEqualStrings(file.content, restored_file.content); + try std.testing.expectEqual(file.location_rows.len, restored_file.location_rows.len); + for (file.location_rows, restored_file.location_rows) |before, after| { + try std.testing.expectEqual(before.kind, after.kind); + try std.testing.expectEqual(before.at, after.at); + try std.testing.expectEqualStrings(before.path, after.path); + try std.testing.expectEqual(before.code_start, after.code_start); + try std.testing.expectEqual(before.location_end, after.location_end); + try std.testing.expectEqualSlices(u8, before.colors, after.colors); + } + restored.active = id; + restored.armLookWalk(id); + restored.update(.{ .key = .{ .cp = 'N' } }); + try std.testing.expectEqual(@as(i32, @intCast(matches[1])), restored.panes[id].?.cur_row); + try std.testing.expectEqualStrings(source_text, source.file.?.content); + } + } +} diff --git a/test/panes.zig b/test/panes.zig index d844f26e..28af8141 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -975,6 +975,7 @@ const TagNameTintTests = struct { for ([_]bool{ false, true }) |bottom| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 24 }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const pane = try p.setTestFile("body\n"); p.gpa.free(pane.file.?.path); pane.file.?.path = try p.gpa.dupe(u8, "/project/界file.zig"); @@ -2129,6 +2130,7 @@ const OutputTests = struct { test "Locations align Unicode paths by display width and preserve source indentation" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); + p.locations_config.layout = .@"inline"; _ = try p.setTestFile("notes\n"); try fillResults(p, 0, "/", .search, "alignment", try p.gpa.dupe(u8, "/界.zig:2:1 first\n/longer-name.zig:12:3 \tsecond\n"), null); const result = p.panes[p.panes[0].?.search_pane.?].?; @@ -2150,9 +2152,10 @@ const OutputTests = struct { for ([_]bool{ false, true }) |tslocations| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = "\tbefore\n\tmatch\n after\n"; const pane = try TreeContextTests.setZigFile(p, source); - p.locations_config = .{ .context = 1, .tscontext = false, .tslocations = tslocations }; + p.locations_config = .{ .layout = .@"inline", .context = 1, .tscontext = false, .tslocations = tslocations }; const path = pane.file.?.path; try fillResults(p, 0, "/", .search, "neighbors", try std.fmt.allocPrint(p.gpa, "{s}:2:1 \tmatch\n", .{path}), 0); const file = &p.panes[pane.search_pane.?].?.file.?; @@ -2183,6 +2186,7 @@ const OutputTests = struct { if (!syntax.enabled) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = "const Box = struct {\n" ++ " pub fn run() void {\n" ++ " // unsaved before\n" ++ @@ -2297,6 +2301,7 @@ const OutputTests = struct { if (!syntax.enabled) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 40 }); defer p.deinit(); + p.locations_config.layout = .@"inline"; const source = "const Outer = struct {\n" ++ " const Inner = struct {\n" ++ " pub fn run(\n" ++ @@ -2306,7 +2311,7 @@ const OutputTests = struct { "\t\tcall();\n" ++ " }\n };\n};\n"; const pane = try TreeContextTests.setZigFile(p, source); - p.locations_config = .{ .context = 1, .tscontext = true, .tslocations = true }; + p.locations_config = .{ .layout = .@"inline", .context = 1, .tscontext = true, .tslocations = true }; const path = pane.file.?.path; try fillResults(p, 0, "/", .search, "nesting", try std.fmt.allocPrint(p.gpa, "{s}:7:1 \t\tcall();\n{s}:2:5 const Inner = struct {{\n", .{ path, path }), 0); const result = p.panes[pane.search_pane.?].?; @@ -2431,6 +2436,7 @@ const OutputTests = struct { test "generated result refills preserve unchanged selections and reset changed text" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); + p.locations_config.layout = .@"inline"; _ = try p.setTestFile("notes\n"); const content = "first\nsecond\nthird\n"; try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, content), null); @@ -2474,6 +2480,7 @@ const OutputTests = struct { test "generated result replacement cancels an in-progress body selection" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); + p.locations_config.layout = .@"inline"; _ = try p.setTestFile("notes\n"); p.presentation.enabled = false; try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, "first\nsecond\nthird\n"), null); @@ -2513,6 +2520,7 @@ const OutputTests = struct { .rows = 24, }); defer p.deinit(); + p.locations_config.layout = .@"inline"; var first: [max_arg]u8 = @splat('a'); var second = first; diff --git a/test/pdf.zig b/test/pdf.zig index 04b2e8b9..113ed8b9 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -1576,12 +1576,20 @@ test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { const results = p.panes[results_id].?.file.?; var found: [3]bool = @splat(false); for (results.location_rows, 0..) |row, index| { - // PDF locations address page/hit pairs, not binary file lines. + // PDF locations address page/hit pairs, not binary file lines. Each + // address owns one snippet, which must not add a navigation stop. + if (index % 2 == 1) { + try std.testing.expectEqual(.preview, row.kind); + try std.testing.expectEqual(results.location_rows[index - 1].at, row.at); + try std.testing.expectEqual(@as(usize, 0), row.code_start); + try std.testing.expectEqualStrings("Pardes", pardes.modal.lineSlice(results.content, index)); + continue; + } try std.testing.expectEqual(.match, row.kind); + const line = pardes.modal.lineSlice(results.content, index); + try std.testing.expectEqual(line.len, row.code_start); if (!std.mem.endsWith(u8, row.path, "design.pdf") or row.at.line != 1) continue; if (row.at.col < 1 or row.at.col > found.len) continue; - const line = pardes.modal.lineSlice(results.content, index); - try std.testing.expectEqualStrings("Pardes", line[row.code_start..]); found[row.at.col - 1] = true; } for (found) |present| try std.testing.expect(present); @@ -1599,6 +1607,7 @@ test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { defer frame.deinit(); try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); try std.testing.expectEqual(@as(usize, 0), pv.search_hit); + try std.testing.expectEqual(@as(usize, 0), pane.search_row.?); const first = try p.render(frame.allocator()); const revision = first.images[0].?.native.revision; const raster = panes.Pdf.rasterForPage(pv, pv.page).?; @@ -1617,12 +1626,14 @@ test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); try std.testing.expectEqual(@as(usize, 1), pv.search_hit); + try std.testing.expectEqual(@as(usize, 2), pane.search_row.?); _ = frame.reset(.retain_capacity); const second = try p.render(frame.allocator()); try std.testing.expectEqual(revision, second.images[0].?.native.revision); try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); try std.testing.expectEqual(@as(usize, 2), pv.search_hit); + try std.testing.expectEqual(@as(usize, 4), pane.search_row.?); _ = frame.reset(.retain_capacity); const third = try p.render(frame.allocator()); try std.testing.expectEqual(revision, third.images[0].?.native.revision); -- cgit v1.3