summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/builtins.zig25
-rw-r--r--src/config.zig1
-rw-r--r--src/dump.zig14
-rw-r--r--src/locations.zig258
-rw-r--r--src/locations_config.zig69
-rw-r--r--src/panes.zig208
-rw-r--r--src/pardes.zig43
-rw-r--r--src/syntax.zig147
8 files changed, 727 insertions, 38 deletions
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]);
+}