From af435ba7e4fd34dd5eab17bd21ce535b6f9b8413 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 15 Sep 2026 17:44:34 -0300 Subject: Align search and LSP results in groups of eight matches --- docs/config.md | 7 +++- src/locations.zig | 94 +++++++++++++++++++++++++++++++---------- test/output.zig | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 25 deletions(-) diff --git a/docs/config.md b/docs/config.md index 2474c6b0..ffd1fb3f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -159,8 +159,11 @@ LocationsConfig context:5 tscontext:on tslocations:off `on`). Declaration headers use the same muted color as locations, with or without their locations visible. -Result locations are padded so the source text aligns, preserving the source's -indentation. All visible locations start flush left; `n` and `N` still stop on matches. +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` +still stop on matches. With `tscontext` enabled, asterisks after a match location show its declaration depth (for example, `main.zig:42 ***`). Ordinary neighboring source lines retain syntax colors; declaration headers do not. diff --git a/src/locations.zig b/src/locations.zig index ebc87a2e..43d99005 100644 --- a/src/locations.zig +++ b/src/locations.zig @@ -46,14 +46,31 @@ pub fn sameInput(input: []const u8, rendered: []const u8, rows: []const Row) boo 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'); - for (rows) |row| { + var width: usize = 0; + for (rows, 0..) |row, index| { if (row.kind != .match) return false; + if (index % alignment_entries == 0) { + width = 0; + var chunk = raw_lines; + for (0..alignment_entries) |_| { + const line = chunk.next() orelse break; + const target = parseRaw(line); + width = @max(width, pardes.panes.File.displayWidth(line[0..target.end])); + } + } 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; + // 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; + const padding = shown[row.location_end..row.code_start]; + 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; } if (raw_lines.next()) |tail| if (tail.len != 0) return false; return raw_lines.next() == null; @@ -136,7 +153,7 @@ fn sourceText(p: *pardes.Pardes, arena: std.mem.Allocator, dir: []const u8, path } /// Inputs are sorted results. Expand each source group, merge context with -/// matches, then align once across the final rows. All temporary source data +/// matches, then align groups of eight matches. All temporary source data /// stays in the caller's scratch arena; returned text and rows are owned. pub fn format(p: *pardes.Pardes, dir: []const u8, input: []const u8, anchor: ?usize, expand: bool) !Result { const arena = p.scratch.allocator(); @@ -210,8 +227,6 @@ pub fn format(p: *pardes.Pardes, dir: []const u8, input: []const u8, anchor: ?us pending.items.len = write; first = end; } - var width: usize = 0; - for (pending.items) |row| width = @max(width, labelWidth(row)); var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); var rows: std.ArrayList(Row) = .empty; @@ -223,32 +238,65 @@ pub fn format(p: *pardes.Pardes, dir: []const u8, input: []const u8, anchor: ?us rows.deinit(p.gpa); } var mapped = anchor; - for (pending.items, 0..) |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); + 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 out.writer.writeAll(row.code); + 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; } - 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 (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; } const owned_rows = try rows.toOwnedSlice(p.gpa); errdefer freeRows(p.gpa, owned_rows); return .{ .content = try out.toOwnedSlice(), .rows = owned_rows, .anchor = mapped }; } +const alignment_entries = 8; + +/// Keep neighboring context with the nearer match at a chunk boundary. Shared +/// context stays deduplicated; ties belong to the preceding chunk. +fn alignmentEnd(rows: []const Pending, first: usize) usize { + var matches: usize = 0; + var previous: usize = first; + for (rows[first..], first..) |row, index| { + if (row.original == null) continue; + if (matches == alignment_entries) { + var end = index; + while (end > previous + 1) { + const context = rows[end - 1]; + if (!std.mem.eql(u8, context.path, row.path)) break; + if (std.mem.eql(u8, rows[previous].path, row.path) and + row.at.line -| context.at.line >= context.at.line -| rows[previous].at.line) break; + end -= 1; + } + return end; + } + matches += 1; + previous = index; + } + return rows.len; +} + fn labelWidth(row: Pending) usize { return pardes.panes.File.displayWidth(row.label) + @as(usize, if (row.depth > 0) row.depth + 1 else 0); diff --git a/test/output.zig b/test/output.zig index f1d9d4e0..b0f964ee 100644 --- a/test/output.zig +++ b/test/output.zig @@ -1146,3 +1146,126 @@ test "Look ignores slash-only comment markers while retaining root and real path try std.testing.expect(look.resolve(null, doubled, ".", &realbuf) == .dir); try std.testing.expect(look.resolve(null, "https://example.com/path", ".", &realbuf) == .url); } + +test "result alignment chunks keep seventeen matches independent and refresh stable" { + for ([_]panes.Output.Origin{ .search, .{ .query = .references } }) |origin| { + for ([_]usize{ 7, 8 }) |long_at| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("notes\n"); + p.locations_config = .{ .context = 0, .tscontext = false }; + var input: std.ArrayList(u8) = .empty; + defer input.deinit(p.gpa); + var widths: [17]usize = undefined; + for (0..17) |reverse| { + const index = 16 - reverse; + const label = try std.fmt.allocPrint(p.gpa, "/r{d:0>2}{s}.zig:3:1", .{ index, if (index == long_at) "-界-very-long-path-name" else "-é" }); + defer p.gpa.free(label); + widths[index] = panes.File.displayWidth(label); + try input.appendSlice(p.gpa, label); + try input.appendSlice(p.gpa, " \t return 7;\n"); + } + try panes.Output.fillResults(p, 0, "/", origin, "chunks", try p.gpa.dupe(u8, input.items), 0); + const result_id = source.search_pane.?; + const result = p.panes[result_id].?; + const file = &result.file.?; + try std.testing.expectEqual(@as(usize, 17), file.location_rows.len); + try std.testing.expectEqual(@as(usize, 16), source.search_row.?); + var starts: [17]usize = undefined; + for (file.location_rows, 0..) |row, index| { + const line = modal.lineSlice(file.content, index); + const first = index / 8 * 8; + var width: usize = 0; + for (widths[first..@min(first + 8, widths.len)]) |label_width| width = @max(width, label_width); + try std.testing.expectEqual(width, panes.File.displayWidth(line[0 .. row.code_start - 1])); + try std.testing.expectEqual(@as(u8, '\t'), line[row.code_start - 1]); + try std.testing.expectEqualStrings("\t return 7;", line[row.code_start..]); + try std.testing.expectEqualStrings(row.path, line[0 .. row.location_end - ":3:1".len]); + starts[index] = row.code_start; + } + const content = file.content.ptr; + const revision = file.revision; + const index = try panes.File.lineIndex(p.gpa, file); + result.cur_row = 8; + result.cur_col = @intCast(file.location_rows[8].code_start); + try panes.Output.fillResults(p, 0, "/", origin, "chunks", try p.gpa.dupe(u8, input.items), 8); + try std.testing.expectEqual(result_id, source.search_pane.?); + try std.testing.expectEqual(content, file.content.ptr); + try std.testing.expectEqual(revision, file.revision); + try std.testing.expectEqual(index.ptr, file.line_starts.ptr); + try std.testing.expectEqual(@as(usize, 8), source.search_row.?); + try std.testing.expectEqual(@as(i32, 8), result.cur_row); + for (file.location_rows, starts) |row, start| try std.testing.expectEqual(start, row.code_start); + + // Simulate a restored view made by the previous global-padding + // formatter. Its metadata still describes the same result bytes. + const expected = try p.gpa.dupe(u8, file.content); + defer p.gpa.free(expected); + var global_width: usize = 0; + for (widths) |width| global_width = @max(global_width, width); + var legacy: std.ArrayList(u8) = .empty; + defer legacy.deinit(p.gpa); + for (file.location_rows, 0..) |*row, row_index| { + const line = modal.lineSlice(file.content, row_index); + const label = line[0..row.location_end]; + const code = line[row.code_start..]; + try legacy.appendSlice(p.gpa, label); + const padding = global_width - panes.File.displayWidth(label); + try legacy.appendNTimes(p.gpa, ' ', padding); + try legacy.append(p.gpa, '\t'); + try legacy.appendSlice(p.gpa, code); + try legacy.append(p.gpa, '\n'); + row.code_start = row.location_end + padding + 1; + } + const retained_rows = file.location_rows; + file.location_rows = &.{}; + panes.File.setContent(p, file, try legacy.toOwnedSlice(p.gpa)); + file.location_rows = retained_rows; + try std.testing.expect(!std.mem.eql(u8, expected, file.content)); + try panes.Output.fillResults(p, 0, "/", origin, "chunks", try p.gpa.dupe(u8, input.items), 8); + try std.testing.expectEqual(result_id, source.search_pane.?); + try std.testing.expectEqualStrings(expected, file.content); + for (file.location_rows, starts) |row, start| try std.testing.expectEqual(start, row.code_start); + } + } +} + +test "result alignment chunks assign boundary context to nearest match with previous ties" { + for ([_]panes.Output.Origin{ .search, .{ .query = .references } }) |origin| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + 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 }; + var input: std.ArrayList(u8) = .empty; + defer input.deinit(p.gpa); + for ([_]usize{ 1, 2, 3, 4, 5, 6, 7, 8, 12 }) |line| { + const entry = try std.fmt.allocPrint(p.gpa, "{s}:{d}:1 \t source();\n", .{ source.file.?.path, line }); + defer p.gpa.free(entry); + try input.appendSlice(p.gpa, entry); + } + try panes.Output.fillResults(p, 0, "/", origin, "context chunks", try p.gpa.dupe(u8, input.items), 8); + const file = &p.panes[source.search_pane.?].?.file.?; + try std.testing.expectEqual(@as(usize, 14), file.location_rows.len); + try std.testing.expectEqual(@as(usize, 11), source.search_row.?); + const first_label = try std.fmt.allocPrint(p.gpa, "{s}:8:1", .{source.file.?.path}); + defer p.gpa.free(first_label); + const next_label = try std.fmt.allocPrint(p.gpa, "{s}:12:1", .{source.file.?.path}); + defer p.gpa.free(next_label); + for (file.location_rows, 0..) |row, index| { + const line = modal.lineSlice(file.content, index); + try std.testing.expectEqual(index + 1, row.at.line); + const expected = if (row.at.line <= 10) first_label else next_label; + try std.testing.expectEqual(panes.File.displayWidth(expected), panes.File.displayWidth(line[0 .. row.code_start - 1])); + try std.testing.expectEqualStrings("\t source();", line[row.code_start..]); + if (row.at.line >= 9 and row.at.line != 12) { + try std.testing.expectEqual(.context, row.kind); + try std.testing.expectEqual(@as(usize, 0), row.location_end); + } else try std.testing.expectEqual(.match, row.kind); + } + } +} -- cgit v1.3