diff options
| -rw-r--r-- | docs/config.md | 22 | ||||
| -rw-r--r-- | src/builtins.zig | 25 | ||||
| -rw-r--r-- | src/config.zig | 1 | ||||
| -rw-r--r-- | src/dump.zig | 14 | ||||
| -rw-r--r-- | src/locations.zig | 258 | ||||
| -rw-r--r-- | src/locations_config.zig | 69 | ||||
| -rw-r--r-- | src/panes.zig | 208 | ||||
| -rw-r--r-- | src/pardes.zig | 43 | ||||
| -rw-r--r-- | src/syntax.zig | 147 | ||||
| -rw-r--r-- | test/output.zig | 26 | ||||
| -rw-r--r-- | test/panes.zig | 126 | ||||
| -rw-r--r-- | test/pdf.zig | 18 |
12 files changed, 911 insertions, 46 deletions
diff --git a/docs/config.md b/docs/config.md index d986855e..e8c25ceb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -128,6 +128,28 @@ scrolling; scrolling upward reveals that source line as the headers recede. `Dump` and `Restore` preserve the setting per pane. Customized tags retain their text; the command can still be executed from any source pane. +`LocationsConfig` prints the current settings for Search, Grep and LSP location +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 +``` + +- `context` is the number of source lines above and below each match (default + `0`). Overlapping context is shown once. +- `tscontext` includes enclosing tree-sitter declaration headers in source + order (default `off`). +- `tslocations` shows locations beside those declaration headers (default + `on`). Turning it off keeps the headers' syntax colors. + +Result locations are padded so the source text aligns, preserving the source's +indentation. Context rows begin with `| `; `n` and `N` still stop on matches. +Open file buffers supply context from their current edits. Missing files still +leave the original result available. These settings apply to subsequent result +generation and survive `Dump`/`Restore`. Invalid fields reject the entire +update; if a field appears twice, its last value wins. + `WindowOpacity <percent>` controls the opacity of everything in the SDL window except text and the cursor, which stay fully opaque. Use `WindowOpacity 85` to see the desktop through the editor, or `WindowOpacity 100` to restore full diff --git a/src/builtins.zig b/src/builtins.zig index f4e22308..92bf6fea 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -523,6 +523,31 @@ pub const Config = struct { } }; +pub const LocationsConfig = struct { + pub const takes_arg = true; + pub const output: OutputTraits = .{ .name = "+LocationsConfig" }; + + pub fn run(c: Ctx) void { + const next = c.p.locations_config.parse(c.arg orelse "") catch |err| { + c.p.reportError(c.id, "LocationsConfig", err); + return; + }; + c.p.locations_config = next; + report(c) catch |err| c.p.reportError(c.id, "LocationsConfig", err); + } + + fn report(c: Ctx) !void { + var out: std.Io.Writer.Allocating = .init(c.p.gpa); + errdefer out.deinit(); + try out.writer.writeAll("LocationsConfig "); + try c.p.locations_config.write(&out.writer); + try out.writer.writeByte('\n'); + const content = try out.toOwnedSlice(); + const dir = if (c.pane.file) |file| std.fs.path.dirname(file.path) orelse "/" else c.pane.cwdSlice(); + try panes.Output.fillResults(c.p, c.id, dir, .{ .cmd = .LocationsConfig }, "", content, null); + } +}; + pub const Messages = struct { pub const output: OutputTraits = .{ .name = config.messages_buffer }; pub fn run(c: Ctx) void { diff --git a/src/config.zig b/src/config.zig index 8e667ce2..555173e4 100644 --- a/src/config.zig +++ b/src/config.zig @@ -57,6 +57,7 @@ pub const leader_path = paths: { .Find = "ff", .Grep = "fg", .Config = "fc", + .LocationsConfig = null, .Tutor = "ht", .Changelog = "hc", .Messages = "hm", diff --git a/src/dump.zig b/src/dump.zig index 8200b0b7..7380c1ed 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -74,12 +74,25 @@ pub const Terminal = struct { cursor: Cursor = .{}, }; +pub const LocationRow = struct { + kind: enum { match, context }, + path: []const u8, + line: usize, + col: usize = 0, + end_line: usize = 0, + end_col: usize = 0, + location_end: usize, + code_start: usize, + colors_b64: []const u8 = "", +}; + pub const File = struct { path: []const u8 = "", content: []const u8 = "", content_b64: []const u8 = "", dirty: bool = false, tree_context: bool = false, + location_rows: []const LocationRow = &.{}, // Builtin names remain stable when enum ordinals change. origin: []const u8 = "", origin_arg: []const u8 = "", @@ -135,6 +148,7 @@ pub const State = struct { topbar: []const u8 = "", topbar_custom: ?[]const u8 = null, theme: []const u8 = "dark", + locations_config: @import("locations_config.zig").Config = .{}, columns: []const Column = &.{}, panes: []const Pane = &.{}, mounts: []const Mount = &.{}, diff --git a/src/locations.zig b/src/locations.zig new file mode 100644 index 00000000..8f8546f6 --- /dev/null +++ b/src/locations.zig @@ -0,0 +1,258 @@ +const std = @import("std"); +const pardes = @import("pardes.zig"); +const look = @import("look.zig"); +const syntax = @import("syntax.zig"); +const filesystem = @import("fs.zig"); + +pub const Config = @import("locations_config.zig").Config; +pub const context_prefix = "| "; + +pub const Row = struct { + kind: enum { match, context }, + path: []u8, + at: look.Spot, + location_end: usize, + code_start: usize, + colors: []u8 = &.{}, +}; + +pub fn freeRows(gpa: std.mem.Allocator, rows: []Row) void { + for (rows) |row| { + gpa.free(row.path); + gpa.free(row.colors); + } + gpa.free(rows); +} + +pub fn equalRows(a: []const Row, b: []const Row) bool { + if (a.len != b.len) return false; + for (a, b) |left, right| { + if (left.kind != right.kind or !std.meta.eql(left.at, right.at) or + left.location_end != right.location_end or left.code_start != right.code_start or + !std.mem.eql(u8, left.path, right.path) or !std.mem.eql(u8, left.colors, right.colors)) return false; + } + return true; +} + +/// 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 { + 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'); + for (rows) |row| { + if (row.kind != .match) return false; + 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 (raw_lines.next()) |tail| if (tail.len != 0) return false; + return raw_lines.next() == null; +} + +pub const Parsed = struct { + path: []const u8, + at: look.Spot, + end: usize, + code_start: usize, +}; + +/// The tab separates alignment from code; source indentation after it is data. +pub fn parse(line: []const u8) Parsed { + if (std.mem.indexOfScalar(u8, line, '\t')) |tab| { + const prefix = std.mem.trimEnd(u8, line[0..tab], " "); + const target = look.parsePathLine(prefix); + if (target.at.line > 0 and target.end == prefix.len) + return .{ .path = target.path, .at = target.at, .end = prefix.len, .code_start = tab + 1 }; + } + return parseRaw(line); +} + +fn parseRaw(line: []const u8) Parsed { + var at: usize = 1; + while (at < line.len) : (at += 1) { + if (line[at] != ':' or at + 1 == line.len or !std.ascii.isDigit(line[at + 1])) continue; + const start = at; + at += 1; + while (at < line.len and (std.ascii.isDigit(line[at]) or line[at] == ':' or line[at] == '-')) at += 1; + if (at < line.len and line[at] != ' ' and line[at] != '\t') continue; + const target = look.parsePathLine(line[start..at]); + if (target.at.line > 0 and target.end == at - start) + return .{ .path = line[0..start], .at = target.at, .end = at, .code_start = @min(line.len, at + 1) }; + } + return .{ .path = line, .at = .{}, .end = line.len, .code_start = line.len }; +} + +pub const Result = struct { + content: []u8, + rows: []Row, + anchor: ?usize, +}; + +const Pending = struct { + path: []const u8, + at: look.Spot, + label: []const u8, + code: []const u8, + original: ?usize, + hidden: bool = false, + colors: []const u8 = &.{}, + + fn lessThan(_: void, a: Pending, b: Pending) bool { + if (a.at.line != b.at.line) return a.at.line < b.at.line; + return (a.original orelse std.math.maxInt(usize)) < (b.original orelse std.math.maxInt(usize)); + } +}; + +fn sourceText(p: *pardes.Pardes, arena: std.mem.Allocator, dir: []const u8, path: []const u8) ?[]const u8 { + if (std.ascii.eqlIgnoreCase(std.fs.path.extension(path), ".pdf")) return null; + const lexical = std.fs.path.resolvePosix(arena, &.{ dir, path }) catch return null; + var pathbuf: [4096]u8 = undefined; + const full = if (filesystem.resolve(p, path, dir, &pathbuf)) |resolved| resolved.path else lexical; + for (p.panes) |slot| if (slot) |pane| { + if (pane.file) |file| if (file.output == null and (std.mem.eql(u8, file.path, full) or std.mem.eql(u8, file.path, lexical))) return file.content; + }; + const bytes = filesystem.read(p, full) catch return null; + defer p.gpa.free(bytes); + return arena.dupe(u8, bytes) catch null; +} + +/// Inputs are sorted results. Expand each source group, merge context with +/// matches, then align once across the final rows. 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(); + var matches: std.ArrayList(Pending) = .empty; + const body = input[0 .. input.len - @intFromBool(std.mem.endsWith(u8, input, "\n"))]; + var lines = std.mem.splitScalar(u8, body, '\n'); + while (lines.next()) |line| { + const target = parseRaw(line); + if (target.at.line == 0) return .{ .content = try p.gpa.dupe(u8, input), .rows = &.{}, .anchor = anchor }; + try matches.append(arena, .{ .path = target.path, .at = target.at, .label = line[0..target.end], .code = line[target.code_start..], .original = matches.items.len }); + } + var pending: std.ArrayList(Pending) = .empty; + var first: usize = 0; + while (first < matches.items.len) { + var end = first + 1; + while (end < matches.items.len and std.mem.eql(u8, matches.items[first].path, matches.items[end].path)) : (end += 1) {} + const path = matches.items[first].path; + const group_start = pending.items.len; + try pending.appendSlice(arena, matches.items[first..end]); + if (expand and (p.locations_config.context > 0 or p.locations_config.tscontext)) { + if (sourceText(p, arena, dir, path)) |source| { + var source_lines: std.ArrayList([]const u8) = .empty; + var source_split = std.mem.splitScalar(u8, source, '\n'); + while (source_split.next()) |line| try source_lines.append(arena, line); + const count = source_lines.items.len; + const wanted = try arena.alloc(u8, count); + @memset(wanted, 0); + const source_colors = syntax.highlightFileRange(arena, path, source, 0, source.len) catch &.{}; + const declarations = if (p.locations_config.tscontext) + syntax.contextDeclarations(arena, path, source) catch &.{} + else + &.{}; + for (matches.items[first..end]) |match| { + const row = match.at.line -| 1; + if (row >= count) continue; + const lo = row -| p.locations_config.context; + const hi = @min(count -| 1, row +| p.locations_config.context); + var context_row = lo; + while (context_row <= hi) : (context_row += 1) { + if (context_row == row) continue; + wanted[context_row] = @max(wanted[context_row], 1); + } + for (declarations) |declaration| { + if (declaration.start_line > row) break; + if (declaration.end_line < row) continue; + var header = declaration.start_line; + while (header <= declaration.header_end_line and header < row) : (header += 1) + wanted[header] = if (p.locations_config.tslocations) @max(wanted[header], 1) else 2; + } + } + for (wanted, 0..) |kind, row| { + if (kind > 0) { + const line = source_lines.items[row]; + const offset = @intFromPtr(line.ptr) - @intFromPtr(source.ptr); + const colors = if (offset + line.len <= source_colors.len) source_colors[offset..][0..line.len] else &.{}; + try appendContext(arena, &pending, path, line, row, kind == 2, colors); + } + } + } + } + std.mem.sort(Pending, pending.items[group_start..], {}, Pending.lessThan); + // Keep every original result, but show each extra source row once. + var write = group_start; + for (pending.items[group_start..]) |row| { + if (row.original == null and write > group_start and pending.items[write - 1].at.line == row.at.line) continue; + pending.items[write] = row; + write += 1; + } + pending.items.len = write; + first = end; + } + var width: usize = 0; + for (pending.items) |row| width = @max(width, pardes.panes.File.displayWidth(row.label) + @as(usize, if (row.original == null) 2 else 0)); + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + var rows: std.ArrayList(Row) = .empty; + errdefer { + for (rows.items) |row| { + p.gpa.free(row.path); + p.gpa.free(row.colors); + } + rows.deinit(p.gpa); + } + var mapped = anchor; + for (pending.items, 0..) |row, index| { + const before = out.written().len; + const context = row.original == null; + if (context) try out.writer.writeAll(context_prefix); + try out.writer.writeAll(row.label); + const location_end = if (row.hidden) 0 else out.written().len - before; + const used = pardes.panes.File.displayWidth(row.label) + @as(usize, if (context) 2 else 0); + try out.writer.splatByteAll(' ', width - used); + 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, .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; + } + 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 appendContext(arena: std.mem.Allocator, rows: *std.ArrayList(Pending), path: []const u8, source: []const u8, row: usize, hidden: bool, colors: []const u8) !void { + try rows.append(arena, .{ + .path = path, + .at = .{ .line = row + 1 }, + .label = if (hidden) "" else try std.fmt.allocPrint(arena, "{s}:{d}", .{ path, row + 1 }), + .code = source, + .original = null, + .hidden = hidden, + .colors = colors, + }); +} + +test "locations tab delimiter preserves source indentation and spaced paths" { + const row = parse("some directory/界.zig:12:3-5 \t\t value"); + try std.testing.expectEqualStrings("some directory/界.zig", row.path); + try std.testing.expectEqual(@as(usize, 12), row.at.line); + try std.testing.expectEqualStrings("\t value", "some directory/界.zig:12:3-5 \t\t value"[row.code_start..]); +} + +test "locations native results retain the first source tab" { + const text = "some directory/a.zig:3:2 \t value"; + const row = parseRaw(text); + try std.testing.expectEqualStrings("some directory/a.zig", row.path); + try std.testing.expectEqualStrings("\t value", text[row.code_start..]); +} diff --git a/src/locations_config.zig b/src/locations_config.zig new file mode 100644 index 00000000..ed67f990 --- /dev/null +++ b/src/locations_config.zig @@ -0,0 +1,69 @@ +const std = @import("std"); + +pub const Config = struct { + context: u16 = 0, + tscontext: bool = false, + tslocations: bool = true, + + /// Apply only supplied fields. A malformed token rejects the whole update. + /// Repeating a field uses its last supplied value. + pub fn parse(current: Config, text: []const u8) !Config { + var result = current; + var tokens = std.mem.tokenizeAny(u8, text, " \t\r\n"); + while (tokens.next()) |token| { + const colon = std.mem.indexOfScalar(u8, token, ':') orelse return error.ExpectedKeyValue; + const key = token[0..colon]; + const value = token[colon + 1 ..]; + var found = false; + inline for (@typeInfo(Config).@"struct".fields) |field| { + if (std.mem.eql(u8, key, field.name)) { + @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, + else => @compileError("unsupported location setting type"), + }; + found = true; + } + } + if (!found) return error.UnknownLocationSetting; + } + return result; + } + + pub fn write(config: Config, writer: *std.Io.Writer) !void { + inline for (@typeInfo(Config).@"struct".fields, 0..) |field, index| { + if (index > 0) try writer.writeByte(' '); + try writer.writeAll(field.name ++ ":"); + const value = @field(config, field.name); + switch (@typeInfo(field.type)) { + .bool => try writer.writeAll(if (value) "on" else "off"), + .int => try writer.print("{d}", .{value}), + else => @compileError("unsupported location setting type"), + } + } + } +}; + +test "LocationsConfig reflection round trip and partial updates" { + var config: Config = .{}; + config = try config.parse("context:5 tscontext:on"); + config = try config.parse("tslocations:off"); + try std.testing.expectEqual(Config{ .context = 5, .tscontext = true, .tslocations = false }, config); + 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.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); +} + +test "LocationsConfig rejects invalid updates atomically" { + const config: Config = .{ .context = 3, .tscontext = true }; + 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")); + 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 32fe8c28..1487e0ef 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -16,6 +16,7 @@ const build_options = @import("pardes_config"); const lsp = @import("lsp/lsp.zig"); const image = @import("image.zig"); const look = @import("look.zig"); +const locations = @import("locations.zig"); const Key = pardes.Key; /// An owned editable buffer and the absolute surface row of its first line. @@ -1011,6 +1012,7 @@ pub const File = struct { line_starts: []usize = &.{}, /// One tree_sitter_gpa-owned syntax.Syn byte per highlighted source byte. highlights: []u8 = &.{}, + location_rows: []locations.Row = &.{}, highlight_start: usize = 0, syntax_dirty: bool = true, tree_context: bool = false, @@ -1040,6 +1042,7 @@ pub const File = struct { .collapsed = pane.collapsed, .file = .{ .tree_context = file.tree_context, + .location_rows = try dumpLocationRows(arena, file.location_rows), .path = file.path, .content = file.content, .content_b64 = try dump.encodeBytes(arena, file.content), @@ -1052,6 +1055,87 @@ pub const File = struct { }; } + fn dumpLocationRows(arena: std.mem.Allocator, rows: []const locations.Row) ![]const dump.LocationRow { + const saved = try arena.alloc(dump.LocationRow, rows.len); + for (rows, saved) |row, *out| out.* = .{ + .kind = @enumFromInt(@intFromEnum(row.kind)), + .path = row.path, + .line = row.at.line, + .col = row.at.col, + .end_line = row.at.end_line, + .end_col = row.at.end_col, + .location_end = row.location_end, + .code_start = row.code_start, + .colors_b64 = try dump.encodeBytes(arena, row.colors), + }; + return saved; + } + + 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; + const rows = try gpa.alloc(locations.Row, saved.len); + var initialized: usize = 0; + errdefer { + for (rows[0..initialized]) |row| { + gpa.free(row.path); + gpa.free(row.colors); + } + gpa.free(rows); + } + var lines = std.mem.splitScalar(u8, content, '\n'); + for (saved, rows) |row, *out| { + 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.kind == .context) != std.mem.startsWith(u8, line, locations.context_prefix) 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 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); + errdefer gpa.free(colors); + if (colors.len != 0 and colors.len != code_len) return error.InvalidLocationRows; + for (colors) |color| if (color > @intFromEnum(syntax.Syn.comment)) return error.InvalidLocationRows; + const path = try gpa.dupe(u8, row.path); + out.* = .{ + .kind = @enumFromInt(@intFromEnum(row.kind)), + .path = path, + .at = .{ .line = row.line, .col = row.col, .end_line = row.end_line, .end_col = row.end_col }, + .location_end = row.location_end, + .code_start = row.code_start, + .colors = colors, + }; + initialized += 1; + } + if (lines.next()) |remaining| if (remaining.len != 0 or lines.next() != null) return error.InvalidLocationRows; + return rows; + } + + test "location metadata restore rejects offsets and invalid syntax bytes" { + const gpa = std.testing.allocator; + var row: dump.LocationRow = .{ + .kind = .context, + .path = "x.zig", + .line = 1, + .location_end = 0, + .code_start = 3, + }; + const rows = try restoreLocationRows(gpa, "| \tx\n", &.{row}); + defer locations.freeRows(gpa, rows); + try std.testing.expectEqualStrings("x.zig", rows[0].path); + row.code_start = 8; + try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, "| \tx\n", &.{row})); + row.code_start = 3; + row.colors_b64 = "/w=="; + try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, "| \tx\n", &.{row})); + row.colors_b64 = ""; + row.kind = .match; + try std.testing.expectError(error.InvalidLocationRows, restoreLocationRows(gpa, "| \tx\n", &.{row})); + } + 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; @@ -1495,6 +1579,10 @@ pub const File = struct { break :blk value; } else null; + if (saved.location_rows.len != 0 and output == null) return error.InvalidLocationRows; + const location_rows = try restoreLocationRows(p.gpa, content, saved.location_rows); + errdefer locations.freeRows(p.gpa, location_rows); + var mini: ?Mini.State = null; errdefer if (mini) |*state| state.deinit(p.gpa); if (saved.mini_source.len > 0) { @@ -1516,6 +1604,7 @@ pub const File = struct { .content = content, .output = output, .mini = mini, + .location_rows = location_rows, .scroll = src.scroll, .revision = @intFromBool(saved.dirty), .history = history, @@ -1538,6 +1627,7 @@ pub const File = struct { if (file.mini) |*mini| mini.deinit(p.gpa); if (file.line_starts.len > 0) p.gpa.free(file.line_starts); if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights); + locations.freeRows(p.gpa, file.location_rows); if (file.context_declarations.len > 0) p.tree_sitter_gpa.free(file.context_declarations); for (file.history.undo[0..file.history.undo_len]) |snap| p.gpa.free(snap.content); for (file.history.redo[0..file.history.redo_len]) |snap| p.gpa.free(snap.content); @@ -1554,6 +1644,8 @@ pub const File = struct { } pub fn setContent(p: *Pardes, f: *State, new: []u8) void { + locations.freeRows(p.gpa, f.location_rows); + f.location_rows = &.{}; for (p.panes) |slot| if (slot) |pane| { if (pane.file) |*file| if (file == f) { for (0..pane.sel.len) |button| { @@ -1712,7 +1804,10 @@ pub const File = struct { @max(start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS + slack)); const new_highlights = (switch (pane.colorAlgo()) { .diff => syntax.highlightDiff(p.tree_sitter_gpa, f.content, start, end), - .locations => syntax.highlightLocations(p.tree_sitter_gpa, f.content, start, end), + .locations => if (f.location_rows.len > 0) + syntax.highlightLocationRows(p.tree_sitter_gpa, f.content, f.location_rows) + else + syntax.highlightLocations(p.tree_sitter_gpa, f.content, start, end), else => syntax.highlightFileRange(p.tree_sitter_gpa, f.path, f.content, start, end), }) catch { f.syntax_dirty = false; @@ -1925,7 +2020,9 @@ pub const File = struct { line.len; } hs = modal.graphemeStart(line, @min(hs, line.len)); - const decoration = Output.rowDecoration(f.output, line); + const row_index: usize = @intCast(@max(0, pane.wrapAt(vr).line)); + const metadata: ?locations.Row = if (row_index < f.location_rows.len) f.location_rows[row_index] else null; + const decoration = Output.decorateRow(f.output, line, metadata); var c: usize = 0; var screen_c: usize = 0; while (hs + c < limit and prefix_width + screen_c < tw) { @@ -2072,23 +2169,28 @@ pub const Output = struct { }; fn rowDecoration(output: ?State, line: []const u8) RowDecoration { + return decorateRow(output, line, null); + } + + fn decorateRow(output: ?State, line: []const u8, metadata: ?locations.Row) RowDecoration { const origin = (output orelse return .{}).from; - const token_end = std.mem.indexOfAny(u8, line, " \t") orelse line.len; - const target = look.parsePathLine(line[0..token_end]); - if (target.at.line == 0 or target.end != token_end) return .{}; - var row: RowDecoration = .{ .location_end = token_end }; - if (token_end == line.len) return row; - const source_start = token_end + 1; + const target = locations.parse(line); + const spot = if (metadata) |m| m.at else target.at; + if (spot.line == 0) return .{}; + var row: RowDecoration = .{ .location_end = if (metadata) |m| m.location_end else target.end }; + const source_start = if (metadata) |m| m.code_start else target.code_start; + if (source_start >= line.len) return row; + if (metadata) |m| if (m.kind == .context) return row; const is_search = switch (origin) { .search => true, .cmd => |cmd| cmd == .Grep, else => false, }; - if (is_search and target.at.col > 0 and target.at.end_line == target.at.line and - target.at.end_col >= target.at.col) + if (is_search and spot.col > 0 and spot.end_line == spot.line and + spot.end_col >= spot.col) { - row.mark_start = source_start +| (target.at.col - 1); - row.mark_end = @min(line.len, source_start +| target.at.end_col); + row.mark_start = source_start +| (spot.col - 1); + row.mark_end = @min(line.len, source_start +| spot.end_col); } const is_diagnostic = switch (origin) { .query => |kind| kind == .diagnostics or kind == .workspace_diagnostics, @@ -2216,22 +2318,13 @@ pub const Output = struct { }; pub fn location(line: []const u8) Location { - const text = std.mem.trimEnd(u8, line, " \t\r\n"); - var at: usize = 1; - while (at < text.len) { - if (text[at] != ':' or at + 1 == text.len or !std.ascii.isDigit(text[at + 1])) { - at += 1; - continue; - } - const start = at; - at += 1; - while (at < text.len and (std.ascii.isDigit(text[at]) or text[at] == ':' or text[at] == '-')) at += 1; - if (at < text.len and text[at] != ' ' and text[at] != '\t') continue; - const parsed = look.parsePathLine(text[start..at]); - if (parsed.at.line == 0 or parsed.end != at - start) continue; - return .{ .path = text[0..start], .at = parsed.at, .end = at }; - } - return .{ .path = text, .at = .{}, .end = text.len }; + const target = locations.parse(std.mem.trimEnd(u8, line, " \t\r\n")); + return .{ .path = target.path, .at = target.at, .end = target.end }; + } + + pub fn isResultRow(file: *const File.State, row: usize) bool { + if (row < file.location_rows.len) return file.location_rows[row].kind == .match; + return !std.mem.startsWith(u8, modal.lineSlice(file.content, row), locations.context_prefix); } fn sortResults(arena: std.mem.Allocator, from: Origin, content: []u8, anchor: ?usize) !?usize { @@ -2328,6 +2421,7 @@ pub const Output = struct { var first: ?usize = null; var row: usize = 0; while (lines.next()) |line| : (row += 1) { + if (std.mem.startsWith(u8, line, locations.context_prefix)) continue; const target = location(line); if (target.at.line == 0) continue; if (first == null) first = row; @@ -2352,10 +2446,45 @@ pub const Output = struct { } pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, initial_anchor: ?usize) !void { - errdefer p.gpa.free(content); + var result_content = content; + errdefer p.gpa.free(result_content); + var result_rows: []locations.Row = &.{}; + errdefer locations.freeRows(p.gpa, result_rows); const pane = p.panes[id] orelse return error.MissingPane; - const anchor = try sortResults(p.scratch.allocator(), from, content, initial_anchor); + if (arg.len > max_arg) return error.ArgumentTooLong; + var anchor = try sortResults(p.scratch.allocator(), from, content, initial_anchor); const by_arg = std.meta.activeTag(from) != .query; + if (p.locations_config.context == 0 and !p.locations_config.tscontext) { + for (p.panes, 0..) |slot, i| { + if (i == id) continue; + const rp = slot orelse continue; + const rf = if (rp.file) |*f| f else continue; + const output = if (rf.output) |*o| o else continue; + 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; + try setArg(output, arg); + p.gpa.free(content); + p.active = id; + if (traits(from).steps) { + pane.search_pane = i; + pane.search_row = anchor; + p.armLookWalk(i); + } + return; + } + } + const format_locations = traits(from).steps and !traits(from).commands and + !std.meta.eql(from, Origin{ .cmd = .Find }) and + !std.meta.eql(from, Origin{ .query = .completion }); + if (format_locations) { + const formatted = try locations.format(p, dir, content, anchor, true); + p.gpa.free(result_content); + result_content = formatted.content; + result_rows = formatted.rows; + anchor = formatted.anchor; + } for (p.panes, 0..) |slot, i| { if (i == id) continue; const rp = slot orelse continue; @@ -2365,12 +2494,22 @@ pub const Output = struct { if (by_arg and !std.mem.eql(u8, o.arg(), arg)) continue; if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue; try setArg(o, arg); - if (std.mem.eql(u8, rf.content, content)) { - p.gpa.free(content); + if (std.mem.eql(u8, rf.content, result_content)) { + p.gpa.free(result_content); } else { - File.setContent(p, rf, content); + File.setContent(p, rf, result_content); resetBody(p, rp); } + if (locations.equalRows(rf.location_rows, result_rows)) { + locations.freeRows(p.gpa, result_rows); + } else { + locations.freeRows(p.gpa, rf.location_rows); + rf.location_rows = result_rows; + if (rf.highlights.len > 0) p.tree_sitter_gpa.free(rf.highlights); + rf.highlights = &.{}; + rf.highlight_start = 0; + rf.syntax_dirty = true; + } p.active = id; if (traits(from).steps) { pane.search_pane = i; @@ -2380,7 +2519,8 @@ pub const Output = struct { return; } const free = p.freeSlot() orelse return error.NoPaneSlots; - const np = try open(p, free, dir, from, arg, content); + const np = try open(p, free, dir, from, arg, result_content); + np.file.?.location_rows = result_rows; p.placeDoc(id, free, np); p.active = id; if (traits(from).steps) { diff --git a/src/pardes.zig b/src/pardes.zig index 8a25f8f9..537b212a 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -7,6 +7,7 @@ pub const modal = @import("modal.zig"); pub const look = @import("look.zig"); pub const filesystem = @import("fs.zig"); pub const syntax = @import("syntax.zig"); +pub const locations_config = @import("locations_config.zig"); const tracy = @import("tracy.zig"); pub const panes = @import("panes.zig"); pub const builtins = @import("builtins.zig"); @@ -1089,6 +1090,32 @@ test "Collapse builtin keeps content and restores the pane from its default tag" try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane)); } +test "LocationsConfig command reports partial updates and survives restore" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("source\n"); + try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig context:5 tscontext:on")); + try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig tslocations:off")); + const expected: locations_config.Config = .{ .context = 5, .tscontext = true, .tslocations = false }; + try std.testing.expectEqual(expected, p.locations_config); + try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig context:2 unknown:on")); + try std.testing.expectEqual(expected, p.locations_config); + var reports: usize = 0; + for (p.panes) |slot| { + const pane = slot orelse continue; + const file = pane.file orelse continue; + 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.expectEqual(@as(usize, 1), reports); + try p.dumpState(); + const restored = try Pardes.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); + defer restored.deinit(); + try std.testing.expectEqual(expected, restored.locations_config); +} + test "TreeContext command and custom tag survive a workspace round trip" { if (comptime !syntax.enabled) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); @@ -5719,6 +5746,7 @@ pub const Pardes = struct { /// hands out Pane.serial; monotonic, never reused next_serial: u32 = 0, settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, + locations_config: locations_config.Config = .{}, tty_filter_palette: panes.Terminal.FilterPalette = .{}, font_request_taken: bool = false, custom_theme: ?Theme = null, @@ -9054,10 +9082,15 @@ pub const Pardes = struct { // fresh results: n starts at the first row, N has nothing behind it const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n") + @intFromBool(rf.content.len > 0 and rf.content[rf.content.len - 1] != '\n')); - const step: i64 = if (pane.search_row) |c| @as(i64, @intCast(c)) + delta else if (delta > 0) 0 else -1; - if (step < 0 or step >= nrows) return true; // armed, nowhere left to go - _ = p.jumpResult(id, @intCast(step)); - p.active = id; + var step: i64 = if (pane.search_row) |c| @as(i64, @intCast(c)) + delta else if (delta > 0) 0 else -1; + if (delta == 0) return true; + const direction: i64 = if (delta > 0) 1 else -1; + while (step >= 0 and step < nrows) : (step += direction) { + if (!panes.Output.isResultRow(rf, @intCast(step))) continue; + _ = p.jumpResult(id, @intCast(step)); + p.active = id; + break; + } return true; } @@ -12051,6 +12084,7 @@ pub const Pardes = struct { .topbar = config.topbar_str, .topbar_custom = if (p.global_tag.custom) p.global_tag.slice("") else null, .theme = p.theme().name, + .locations_config = p.locations_config, .columns = columns[0..columns_len], .panes = dump_panes[0..panes_len], .mounts = mounts[0..p.fs.mounts.items.len], @@ -12114,6 +12148,7 @@ pub const Pardes = struct { var parsed = try dump.readZon(gpa, zon_bytes, "load"); defer parsed.deinit(); const st = parsed.value; + p.locations_config = st.locations_config; for (st.mounts) |mount| { var already_mounted = false; for (p.fs.mounts.items) |existing| { diff --git a/src/syntax.zig b/src/syntax.zig index 69a69bda..8209dc2c 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -617,6 +617,86 @@ pub fn highlightLocations(gpa: std.mem.Allocator, content: []const u8, start_byt } return styles; } + +/// Formatted results carry the exact source boundary independently of their +/// padded location column. This also colors context whose location is hidden. +/// Consecutive source rows share one parse, retaining multiline syntax. +pub fn highlightLocationRows(gpa: std.mem.Allocator, content: []const u8, rows: anytype) ![]u8 { + if (!enabled or content.len == 0 or rows.len == 0) return &.{}; + const styles = try gpa.alloc(u8, content.len); + errdefer gpa.free(styles); + @memset(styles, 0); + const code = try gpa.alloc(u8, content.len); + defer gpa.free(code); + const colors = try gpa.alloc(u8, content.len); + defer gpa.free(colors); + var painted = false; + var row_index: 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; + } + const language = selected orelse continue; + if (offset > 0 and content[offset - 1] == '\n') { + code[code_len] = '\n'; + code_len += 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); + } + } + // A shown context range can begin inside a comment/string whose opener was + // omitted. Formatter snapshots from the complete source take precedence, + // including zero styles that remove misleading fragment-parser captures. + if (comptime @hasField(@TypeOf(rows[0]), "colors")) { + offset = 0; + for (rows) |row| { + 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); + const len = @min(row.colors.len, end - source_start); + if (len > 0) { + @memcpy(styles[source_start..][0..len], row.colors[0..len]); + painted = true; + } + offset = @min(end + 1, content.len); + } + } + if (!painted) { + gpa.free(styles); + return &.{}; + } + return styles; +} + fn codeAfterLocation(line: []const u8) ?struct { path: []const u8, at: usize, text: []const u8 } { const token_end = std.mem.indexOfAny(u8, line, " \t") orelse return null; if (token_end == 0) return null; @@ -628,6 +708,48 @@ fn codeAfterLocation(line: []const u8) ?struct { path: []const u8, at: usize, te if (at >= line.len) return null; return .{ .path = target.path, .at = at, .text = line[at..] }; } + +test "syntax formatted locations color hidden context and preserve multiline source" { + if (!enabled or (!minimal_grammars and !full_grammars)) return; + const gpa = std.testing.allocator; + start(gpa); + defer stop(); + const Row = struct { path: []const u8, code_start: usize, at: look.Spot }; + const first = "a path.c:1 \t/* open"; + const second = " \t| comment body"; + const third = "a path.c:3 \t*/ int value = 42;"; + const content = first ++ "\n" ++ second ++ "\n" ++ third ++ "\n"; + const rows = [_]Row{ + .{ .path = "a path.c", .code_start = "a path.c:1 \t".len, .at = .{ .line = 1 } }, + .{ .path = "a path.c", .code_start = " \t| ".len, .at = .{ .line = 2 } }, + .{ .path = "a path.c", .code_start = "a path.c:3 \t".len, .at = .{ .line = 3 } }, + }; + const styles = try highlightLocationRows(gpa, content, &rows); + defer gpa.free(styles); + const body = std.mem.indexOf(u8, content, "comment body").?; + try std.testing.expectEqual(@intFromEnum(Syn.comment), styles[body]); + try std.testing.expectEqual(@intFromEnum(Syn.none), styles[body - 1]); + const number = std.mem.indexOf(u8, content, "42").?; + try std.testing.expectEqual(@intFromEnum(Syn.number), styles[number]); + try std.testing.expectEqual(@intFromEnum(Syn.none), styles[0]); +} + +test "syntax formatted locations separate alignment from Markdown indentation" { + if (!enabled or !full_grammars) return; + const gpa = std.testing.allocator; + start(gpa); + defer stop(); + const Row = struct { path: []const u8, code_start: usize, at: look.Spot }; + const content = "short.md:1 \t# Heading\n" ++ "longer path.md:5 \t # Code\n"; + const rows = [_]Row{ + .{ .path = "short.md", .code_start = "short.md:1 \t".len, .at = .{ .line = 1 } }, + .{ .path = "longer path.md", .code_start = "longer path.md:5 \t".len, .at = .{ .line = 5 } }, + }; + const styles = try highlightLocationRows(gpa, content, &rows); + defer gpa.free(styles); + try std.testing.expectEqual(@intFromEnum(Syn.keyword), styles[std.mem.indexOf(u8, content, "Heading").?]); + try std.testing.expectEqual(@intFromEnum(Syn.string), styles[std.mem.indexOf(u8, content, "Code").?]); +} fn inject(styles: []u8, source: []const u8, node: ts.Node, markdown: bool) void { const kind = node.kind(); if (markdown and std.mem.eql(u8, kind, "inline")) { @@ -1056,3 +1178,28 @@ test "syntax allocator switching clears default-runtime caches" { try std.testing.expectEqual(@intFromEnum(Syn.keyword), restored[0]); try std.testing.expectEqual(@intFromEnum(Syn.keyword), restored[1]); } + +test "syntax location context snapshots retain omitted multiline comment scope" { + if (!enabled or (!minimal_grammars and !full_grammars)) return; + const gpa = std.testing.allocator; + start(gpa); + defer stop(); + const source = "/* documentation\nconst int value = 42;\n*/\n"; + const source_colors = try highlightFileRange(gpa, "a.c", source, 0, source.len); + defer gpa.free(source_colors); + const code = "const int value = 42;"; + const source_start = std.mem.indexOf(u8, source, code).?; + const Row = struct { path: []const u8, code_start: usize, at: look.Spot, colors: []const u8 }; + const content = "| \t" ++ code ++ "\n"; + const rows = [_]Row{.{ + .path = "a.c", + .code_start = "| \t".len, + .at = .{ .line = 2 }, + .colors = source_colors[source_start..][0..code.len], + }}; + const styles = try highlightLocationRows(gpa, content, &rows); + defer gpa.free(styles); + for (styles[rows[0].code_start..][0..code.len]) |style| + try std.testing.expectEqual(@intFromEnum(Syn.comment), style); + try std.testing.expectEqual(@intFromEnum(Syn.none), styles[0]); +} diff --git a/test/output.zig b/test/output.zig index fd4252e1..42b1ce7c 100644 --- a/test/output.zig +++ b/test/output.zig @@ -11,6 +11,24 @@ const platform = pardes.platform; const font_picker = pardes.font_picker; const fonts = if (font_picker) pardes.fonts else struct {}; +// Sorting tests compare original locations and source bytes independently of +// alignment. Dedicated Locations tests verify the rendered column widths. +fn expectResultRows(expected: []const u8, file: *const panes.File.State) !void { + if (file.location_rows.len == 0) return std.testing.expectEqualStrings(expected, file.content); + try std.testing.expectEqual(std.mem.endsWith(u8, expected, "\n"), std.mem.endsWith(u8, file.content, "\n")); + var source = std.mem.splitScalar(u8, expected, '\n'); + var shown = std.mem.splitScalar(u8, file.content, '\n'); + for (file.location_rows) |row| { + const want = source.next() orelse return error.MissingExpectedResult; + const actual = shown.next() orelse return error.MissingRenderedResult; + const target = panes.Output.location(want); + try std.testing.expectEqualStrings(want[0..target.end], actual[0..row.location_end]); + try std.testing.expectEqualStrings(want[@min(want.len, target.end + 1)..], actual[row.code_start..]); + } + try std.testing.expectEqualStrings("", source.next() orelse ""); + try std.testing.expect(source.next() == null); +} + test "search tint survives wrapping and theme changes while selections remain authoritative" { const text = "nested/path/a.txt:1:5-7 let abc = 1;\n"; for ([_]u16{ 30, 80 }) |width| { @@ -63,7 +81,7 @@ test "location results sort filenames and numeric positions while remapping thei 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.?].?; - try std.testing.expectEqualStrings(after, results.file.?.content); + try expectResultRows(after, &results.file.?); try std.testing.expectEqual(@as(usize, 3), source.search_row.?); } } @@ -106,7 +124,7 @@ test "reference Look selects the next sorted row from the captured request and k p.lspResponse(request, response); const rid = source.search_pane orelse return error.MissingResults; const results = p.panes[rid].?; - try std.testing.expectEqualStrings(sorted, results.file.?.content); + try expectResultRows(sorted, &results.file.?); try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expectEqual(@as(i32, 3), source.cur_row); try std.testing.expect(source.vsel.active); @@ -159,7 +177,7 @@ test "location sorting leaves mixed prose alone and preserves newline shape" { defer p.deinit(); const source = try p.setTestFile("notes\n"); try panes.Output.fillResults(p, 0, "/tmp", .search, "", try p.gpa.dupe(u8, case.before), 1); - try std.testing.expectEqualStrings(case.after, p.panes[source.search_pane.?].?.file.?.content); + try expectResultRows(case.after, &p.panes[source.search_pane.?].?.file.?); try std.testing.expectEqual(case.anchor, source.search_row.?); } } @@ -218,7 +236,7 @@ test "reordered identical results preserve the selected row and warm line index" result.select = true; const selected = result.vsel; try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "new request", try p.gpa.dupe(u8, "a.zig:10:1 second\na.zig:2:1 first\n"), 0); - try std.testing.expectEqualStrings(sorted, file.content); + try expectResultRows(sorted, file); try std.testing.expectEqual(content, file.content.ptr); try std.testing.expectEqual(index.ptr, file.line_starts.ptr); try std.testing.expectEqual(revision, file.revision); diff --git a/test/panes.zig b/test/panes.zig index 073a9668..97dc3927 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -1597,6 +1597,132 @@ const OutputTests = struct { const fromWord = panes.Output.fromWord; const fillResults = panes.Output.fillResults; + 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(); + _ = 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.?].?; + const file = &result.file.?; + try std.testing.expectEqual(@as(usize, 2), file.location_rows.len); + var column: ?usize = null; + for (file.location_rows, 0..) |row, i| { + const line = modal.lineSlice(file.content, i); + try std.testing.expectEqual(.match, row.kind); + const current = panes.File.displayWidth(line[0..row.code_start]); + if (column) |previous| try std.testing.expectEqual(previous, current); + column = current; + const expected = if (std.mem.eql(u8, row.path, "/界.zig")) " first" else "\tsecond"; + try std.testing.expectEqualStrings(expected, line[row.code_start..]); + } + } + + test "Locations context uses unsaved sources and navigation visits only matches" { + if (!syntax.enabled) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + const source = "const Box = struct {\n" ++ + " pub fn run() void {\n" ++ + " // unsaved before\n" ++ + " first();\n" ++ + " // shared context\n" ++ + " second();\n" ++ + " // unsaved after\n" ++ + " }\n};\n"; + const pane = try TreeContextTests.setZigFile(p, source); + p.locations_config.context = 1; + p.locations_config.tscontext = true; + p.locations_config.tslocations = false; + const path = pane.file.?.path; + const input = try std.fmt.allocPrint(p.gpa, "{s}:6:9 second();\n{s}:4:9 first();\n", .{ path, path }); + // Original first result is the later source match: sorting and inserted + // context must both preserve its anchor. + try fillResults(p, 0, "/", .search, "context", input, 0); + const result = p.panes[pane.search_pane.?].?; + const file = &result.file.?; + const expected_lines = [_]usize{ 1, 2, 3, 4, 5, 6, 7 }; + try std.testing.expectEqual(expected_lines.len, file.location_rows.len); + var matches: [2]usize = undefined; + var count: usize = 0; + for (file.location_rows, expected_lines, 0..) |row, expected_line, i| { + try std.testing.expectEqual(expected_line, row.at.line); + try std.testing.expectEqualStrings(path, row.path); + const line = modal.lineSlice(file.content, i); + if (row.kind == .match) { + matches[count] = i; + count += 1; + } else { + try std.testing.expect(std.mem.startsWith(u8, line, "| ")); + try std.testing.expect(!panes.Output.isResultRow(file, i)); + try std.testing.expectEqualStrings(modal.lineSlice(source, expected_line - 1), line[row.code_start..]); + } + if (expected_line <= 2) try std.testing.expectEqual(@as(usize, 0), row.location_end); + } + try std.testing.expectEqual(@as(usize, 2), count); + try std.testing.expectEqual(matches[1], pane.search_row.?); + try std.testing.expect(pardes.test_api.searchStep(p, 0, -1)); + try std.testing.expectEqual(matches[0], pane.search_row.?); + try std.testing.expectEqual(@as(i32, 3), pane.cur_row); + try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); + try std.testing.expectEqual(matches[1], pane.search_row.?); + try std.testing.expectEqual(@as(i32, 5), pane.cur_row); + try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); + try std.testing.expectEqual(matches[1], pane.search_row.?); + + pane.search_row = null; + try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); + try std.testing.expectEqual(matches[0], pane.search_row.?); + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const declaration = file.location_rows[0]; + const keyword = declaration.code_start + std.mem.indexOf(u8, modal.lineSlice(source, 0), "struct").?; + const styles = file.highlights[keyword..][0.."struct".len]; + var colored = false; + for (styles) |style| colored = colored or style != 0; + try std.testing.expect(colored); + + try p.dumpState(); + const restored = try Pardes.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); + defer restored.deinit(); + const restored_source = restored.panes[0].?; + const restored_id = pane.search_pane.?; + const restored_result = restored.panes[restored_id].?; + const restored_file = &restored_result.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.expectEqualStrings(before.path, after.path); + try std.testing.expectEqual(before.at, after.at); + try std.testing.expectEqual(before.location_end, after.location_end); + try std.testing.expectEqual(before.code_start, after.code_start); + try std.testing.expectEqualSlices(u8, before.colors, after.colors); + } + _ = try restored.render(frame.allocator()); + try std.testing.expectEqualSlices(u8, styles, restored_file.highlights[keyword..][0.."struct".len]); + // Search links are transient UI state; the restored output metadata + // still governs stepping when that output is attached to a source. + restored_source.search_pane = restored_id; + restored_source.search_row = null; + try std.testing.expect(pardes.test_api.searchStep(restored, 0, 1)); + try std.testing.expectEqual(matches[0], restored_source.search_row.?); + try std.testing.expect(pardes.test_api.searchStep(restored, 0, 1)); + try std.testing.expectEqual(matches[1], restored_source.search_row.?); + try std.testing.expect(pardes.test_api.searchStep(restored, 0, -1)); + try std.testing.expectEqual(matches[0], restored_source.search_row.?); + + // Once an edit invalidates source metadata, the explicit marker still + // prevents context declarations from becoming search destinations. + panes.File.setContent(p, file, try p.gpa.dupe(u8, file.content)); + try std.testing.expectEqual(@as(usize, 0), file.location_rows.len); + pane.search_row = null; + try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); + try std.testing.expectEqual(matches[0], pane.search_row.?); + try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); + try std.testing.expectEqual(matches[1], pane.search_row.?); + } + test "replacing generated Help clears stale selection before executing a new row" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 40 }); defer p.deinit(); diff --git a/test/pdf.zig b/test/pdf.zig index 4c0daf34..a835c6ea 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -1408,12 +1408,22 @@ test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { defer page_hits.deinit(gpa); try std.testing.expect(page_hits.hit_count >= 3); + p.locations_config.context = 2; + p.locations_config.tscontext = true; try p.runSearch(0, "Pardes", .text, .top); const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; - const rows = p.panes[results_id].?.file.?.content; - try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:1 Pardes\n") != null); - try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:2 Pardes\n") != null); - try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:3 Pardes\n") != null); + 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. + try std.testing.expectEqual(.match, row.kind); + 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); // Exaggerate the cell aspect only to make the three fixture hits occupy // distinct fit-width crops. The search/reveal math must use the same |
