From 96725011b43d7bf56ac02e710ea4ef4a76f05351 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 15 Sep 2026 16:44:18 -0300 Subject: Make location highlighting an explicit output producer choice --- docs/config.md | 4 +++ src/builtins.zig | 8 +++--- src/panes.zig | 30 ++++++++++++++++++--- src/pardes.zig | 19 ++++++-------- test/output.zig | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 122 insertions(+), 18 deletions(-) diff --git a/docs/config.md b/docs/config.md index 2a1825ce..42163f2b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -138,6 +138,10 @@ it. `Config` reports the appearance option, and dumps preserve it. Search, Grep and LSP location-result panes include `LocationsConfig` in their default tags. Custom tags keep their edits. +Location highlighting is enabled by the command that produces a location list. +Plain reports, including `LocationsConfig`, keep ordinary text colors even +when their text resembles a location. `Mini` keeps its own syntax colors. + `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: diff --git a/src/builtins.zig b/src/builtins.zig index 18ea0198..765fa93c 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -95,6 +95,8 @@ pub const OutputTraits = struct { name: []const u8, steps: bool = false, jumps: bool = false, + // This producer emits location rows whose labels and source excerpts may be styled. + locations: bool = false, commands: bool = false, doc: bool = false, // Saving promotes this scratch buffer into an ordinary file. @@ -357,7 +359,7 @@ pub const PdfTint = struct { }; pub const PdfSections = struct { - pub const output: OutputTraits = .{ .name = config.pdf_sections_buffer, .steps = true }; + pub const output: OutputTraits = .{ .name = config.pdf_sections_buffer, .steps = true, .locations = true }; pub const enabled = pardes.pdf_enabled; pub fn run(c: Ctx) void { if (comptime enabled) apply(c) else unreachable; @@ -618,7 +620,7 @@ pub const Find = struct { /// matches file CONTENTS under every pane's directory at once. pub const Grep = struct { pub const takes_arg = true; - pub const output: OutputTraits = .{ .name = config.search_buffer, .steps = true }; + pub const output: OutputTraits = .{ .name = config.search_buffer, .steps = true, .locations = true }; pub fn run(c: Ctx) void { const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (pat.len > 0) return c.p.runSearch(c.id, pat, .grep, .top) catch |err| @@ -682,7 +684,7 @@ pub const Last = struct { }; pub const Jumplist = struct { - pub const output: OutputTraits = .{ .name = config.jumps_buffer, .steps = true }; + pub const output: OutputTraits = .{ .name = config.jumps_buffer, .steps = true, .locations = true }; pub fn run(c: Ctx) void { panes.Output.openJumps(c.p, c.id) catch |err| c.p.reportError(c.id, "jumplist", err); } diff --git a/src/panes.zig b/src/panes.zig index 886d4b28..315b0b0f 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -271,8 +271,13 @@ pub const Pane = struct { pub fn colorAlgo(pane: *const Pane) ColorAlgo { if (pane.isTerminal()) return .tty; if (pane.file) |f| { + if (f.mini != null) return .source; if (std.mem.endsWith(u8, f.path, ".diff") or std.mem.endsWith(u8, f.path, ".patch")) return .diff; - if (f.output != null and !Output.fileTraits(f.output).saves) return .locations; + if (f.output != null) { + const tr = Output.fileTraits(f.output); + if (tr.locations) return .locations; + if (!tr.saves) return .none; + } return .source; } return .none; @@ -1811,6 +1816,7 @@ pub const File = struct { else @max(start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS + slack)); const new_highlights = (switch (pane.colorAlgo()) { + .none => @as([]u8, &.{}), .diff => syntax.highlightDiff(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) @@ -2187,6 +2193,7 @@ pub const Output = struct { fn decorateRow(output: ?State, line: []const u8, metadata: ?locations.Row) RowDecoration { const origin = (output orelse return .{}).from; + if (!traits(origin).locations) return .{}; const target = locations.parse(line); const spot = if (metadata) |m| m.at else target.at; if (spot.line == 0) return .{}; @@ -2238,6 +2245,18 @@ pub const Output = struct { try std.testing.expect(malformed.mark_end <= "src/a.rs:3:99999999999999999999-99999999999999999999 text".len); } + 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); + const prose = rowDecoration(.{ .from = .{ .query = .hover } }, "src/a.rs:3:5-7 error: prose"); + try std.testing.expectEqualDeep(RowDecoration{}, prose); + const errors = rowDecoration(.{ .from = .errors }, "src/a.rs:3:5-7 error: prose"); + try std.testing.expectEqualDeep(RowDecoration{}, errors); + try std.testing.expect(!traits(.{ .cmd = .Find }).locations); + try std.testing.expect(traits(.{ .cmd = .Grep }).locations); + try std.testing.expect(traits(.{ .query = .completion }).locations); + } + pub fn setArg(o: *State, text: []const u8) error{ArgumentTooLong}!void { if (text.len > max_arg) return error.ArgumentTooLong; o.arg_len = @intCast(text.len); @@ -2251,18 +2270,19 @@ pub const Output = struct { pub fn traits(o: Origin) Traits { return switch (o) { // rows are `location text`, so n/N walk them - .search => .{ .name = config.search_buffer, .steps = true }, + .search => .{ .name = config.search_buffer, .steps = true, .locations = true }, .errors => .{ .name = config.errors_buffer, .doc = true }, - .pdf_links => .{ .name = config.pdf_links_buffer, .steps = true }, + .pdf_links => .{ .name = config.pdf_links_buffer, .steps = true, .locations = true }, .cmd => |b| builtins.registry.outputTraits(b) orelse unreachable, .query => |k| switch (k) { .hover => .{ .name = config.hover_buffer }, // prose: an action list, a diff, a report about the backend .code_action, .format, .status, .explain => .{ .name = config.lsp_buffer }, - .rename => .{ .name = config.search_buffer, .steps = true }, + .rename => .{ .name = config.search_buffer, .steps = true, .locations = true }, .definition, .declaration, .type_definition, .implementation, .references => .{ .name = config.search_buffer, .steps = true, + .locations = true, .jumps = true, }, // The hierarchy kinds behave like references: a list of places, @@ -2270,11 +2290,13 @@ pub const Output = struct { .incoming_calls, .outgoing_calls, .supertypes, .subtypes => .{ .name = config.search_buffer, .steps = true, + .locations = true, .jumps = true, }, .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs, .completion => .{ .name = config.search_buffer, .steps = true, + .locations = true, }, }, }; diff --git a/src/pardes.zig b/src/pardes.zig index 56f95040..25f4e18e 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -13827,17 +13827,14 @@ pub const Pardes = struct { } const tz_color = tracy.zone(@src(), "paneRecolor"); - switch (pane.colorAlgo()) { - .tty => if (p.settings.colors) panes.Terminal.recolorAnsi(p, pane, r, tx, tw, body_h, body), - // `.locations` joins them because it feeds the same `f.highlights` - // — only the pass that FILLED it differs (refreshHighlights). - .source, .diff, .locations => { - const f = &pane.file.?; - panes.File.drawGutter(p, pane, r, tx, tw, body_h, active); - if (p.settings.colors) panes.File.recolorSyntax(p, pane, f, r, tx, tw, body_h); - panes.File.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg); - }, - .none => {}, + // File layout is independent of whether its producer requests colors. + if (pane.file) |*f| { + panes.File.drawGutter(p, pane, r, tx, tw, body_h, active); + if (p.settings.colors and pane.colorAlgo() != .none) + panes.File.recolorSyntax(p, pane, f, r, tx, tw, body_h); + panes.File.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg); + } else if (pane.isTerminal() and p.settings.colors) { + panes.Terminal.recolorAnsi(p, pane, r, tx, tw, body_h, body); } tz_color.end(); diff --git a/test/output.zig b/test/output.zig index 2ccd951a..f1d9d4e0 100644 --- a/test/output.zig +++ b/test/output.zig @@ -72,6 +72,85 @@ test "search tint survives wrapping and theme changes while selections remain au } } +fn outputCell(p: *Pardes, surface: *pardes.Surface, id: usize, byte: usize) *pardes.Cell { + const pane = p.panes[id].?; + const line = modal.lineSlice(pane.file.?.content, 0); + const rect = p.rects[id]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane) + @as(u16, @intCast(panes.File.displayWidth(line[0..byte]))); + const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + return surface.at(x, y); +} + +fn resultId(p: *Pardes) !usize { + for (p.panes, 0..) |slot, id| if (slot) |pane| { + if (pane.file) |file| if (file.output != null) return id; + }; + return error.MissingOutput; +} + +test "output callers opt into location highlighting instead of matching prose colons" { + 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(); + _ = try p.setTestFile("ordinary\n"); + p.presentation.enabled = false; + if (origin == .cmd and origin.cmd == .LocationsConfig) { + p.locations_config.context = 5; + try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig")); + } else { + try panes.Output.fillResults(p, 0, "/", origin, "plain report", try p.gpa.dupe(u8, "source.zig:3:5-7 warning: context:5\n"), null); + } + const id = try resultId(p); + const file = &p.panes[id].?.file.?; + try std.testing.expectEqual(@as(usize, 0), file.location_rows.len); + pardes.layout.compute(p); + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + const surface = try p.render(frame.allocator()); + const line = modal.lineSlice(file.content, 0); + const before_colon = std.mem.indexOfScalar(u8, line, ':').?; + const normal: pardes.Color = if (p.theme().fg) |fg| .{ .rgb = fg } else .default; + for (0..before_colon + 2) |byte| { + const cell = outputCell(p, surface, id, byte); + try std.testing.expectEqual(normal, cell.style.fg); + try std.testing.expect(!cell.style.bold); + } + } +} + +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(); + _ = try p.setTestFile("ordinary\n"); + p.presentation.enabled = false; + const diagnostic = origin == .query and origin.query == .diagnostics; + const code = if (diagnostic) "warning: unused value" else "const value = 42;"; + try panes.Output.fillResults(p, 0, "/", origin, "styles", try std.fmt.allocPrint(p.gpa, "source.zig:3:1-3 {s}\n", .{code}), null); + const id = try resultId(p); + const file = &p.panes[id].?.file.?; + try std.testing.expectEqual(@as(usize, 1), file.location_rows.len); + pardes.layout.compute(p); + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + const surface = try p.render(frame.allocator()); + const chrome = pardes.ChromeTheme.fromTheme(p.theme()); + try std.testing.expectEqual(pardes.Color{ .rgb = chrome.lineno }, outputCell(p, surface, id, 0).style.fg); + const start = file.location_rows[0].code_start; + const first = outputCell(p, surface, id, start); + if (diagnostic) { + try std.testing.expectEqual(pardes.Color{ .rgb = chrome.diagnostic_warning }, first.style.fg); + try std.testing.expect(first.style.bold); + } else if (origin == .search or origin == .cmd) { + try std.testing.expectEqual(pardes.Color{ .rgb = chrome.search_bg }, first.style.bg); + } + if (!diagnostic and pardes.syntax.enabled) { + const number = start + std.mem.indexOf(u8, code, "42").?; + try std.testing.expectEqual(pardes.Color{ .rgb = p.theme().num }, outputCell(p, surface, id, number).style.fg); + } + } +} + test "location results sort filenames and numeric positions while remapping their anchor" { const before = "z.zig:1:1 z\nb.zig:10:2-3 ten\nb.zig:2:10-12 two-b\nb.zig:2:2-3 two-a\na.zig:99:1 a\nb.zig:2:2-3 duplicate\n"; const after = "a.zig:99:1 a\nb.zig:2:2-3 two-a\nb.zig:2:2-3 duplicate\nb.zig:2:10-12 two-b\nb.zig:10:2-3 ten\nz.zig:1:1 z\n"; -- cgit v1.3