Add project text search panel
This commit is contained in:
+145
@@ -10,6 +10,8 @@ test {
|
||||
pub const Error = error{
|
||||
InvalidPath,
|
||||
InvalidPattern,
|
||||
InvalidQuery,
|
||||
InvalidSearchRow,
|
||||
FileNotFound,
|
||||
};
|
||||
|
||||
@@ -84,6 +86,33 @@ pub const Index = struct {
|
||||
return Error.FileNotFound;
|
||||
}
|
||||
|
||||
pub fn searchRowsAlloc(self: *const Index, allocator: std.mem.Allocator, query: []const u8, include_ignored: bool) ![][]const u8 {
|
||||
try validateQuery(query);
|
||||
var rows = std.ArrayList([]const u8).empty;
|
||||
errdefer {
|
||||
for (rows.items) |row| allocator.free(row);
|
||||
rows.deinit(allocator);
|
||||
}
|
||||
for (self.entries.items) |entry| {
|
||||
if (!include_ignored and self.isIgnored(entry.path)) continue;
|
||||
try appendSearchRowsForEntry(allocator, &rows, entry, query);
|
||||
}
|
||||
return rows.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
pub fn searchOffsetFromRow(self: *const Index, row: []const u8) !struct { path: []const u8, offset: usize } {
|
||||
const first = std.mem.indexOfScalar(u8, row, ':') orelse return Error.InvalidSearchRow;
|
||||
const rest = row[first + 1 ..];
|
||||
const second_rel = std.mem.indexOfScalar(u8, rest, ':') orelse return Error.InvalidSearchRow;
|
||||
const third_rel = std.mem.indexOfScalar(u8, rest[second_rel + 1 ..], ':') orelse return Error.InvalidSearchRow;
|
||||
const path = row[0..first];
|
||||
const line_no = std.fmt.parseUnsigned(usize, rest[0..second_rel], 10) catch return Error.InvalidSearchRow;
|
||||
const column = std.fmt.parseUnsigned(usize, rest[second_rel + 1 .. second_rel + 1 + third_rel], 10) catch return Error.InvalidSearchRow;
|
||||
if (line_no == 0 or column == 0) return Error.InvalidSearchRow;
|
||||
const file_content = try self.content(path);
|
||||
return .{ .path = path, .offset = try byteOffsetForLineColumn(file_content, line_no, column) };
|
||||
}
|
||||
|
||||
pub fn isIgnored(self: *const Index, path: []const u8) bool {
|
||||
for (self.ignore_patterns.items) |pattern| {
|
||||
if (matchesPattern(pattern, path)) return true;
|
||||
@@ -109,6 +138,67 @@ fn validatePattern(pattern: []const u8) !void {
|
||||
}
|
||||
}
|
||||
|
||||
fn validateQuery(query: []const u8) !void {
|
||||
if (query.len == 0) return Error.InvalidQuery;
|
||||
if (!std.unicode.utf8ValidateSlice(query)) return Error.InvalidQuery;
|
||||
for (query) |byte| {
|
||||
if (byte <= 0x20 or byte == '|') return Error.InvalidQuery;
|
||||
}
|
||||
}
|
||||
|
||||
fn appendSearchRowsForEntry(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), entry: Entry, query: []const u8) !void {
|
||||
var line_no: usize = 1;
|
||||
var lines = std.mem.splitScalar(u8, entry.content, '\n');
|
||||
while (lines.next()) |line| : (line_no += 1) {
|
||||
if (std.mem.indexOf(u8, line, query)) |column_zero| {
|
||||
const preview = try previewAlloc(allocator, line);
|
||||
defer allocator.free(preview);
|
||||
try rows.append(allocator, try std.fmt.allocPrint(
|
||||
allocator,
|
||||
"{s}:{d}:{d}:{s}",
|
||||
.{ entry.path, line_no, column_zero + 1, preview },
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn previewAlloc(allocator: std.mem.Allocator, line: []const u8) ![]u8 {
|
||||
var out = std.ArrayList(u8).empty;
|
||||
errdefer out.deinit(allocator);
|
||||
var i: usize = 0;
|
||||
while (i < line.len and out.items.len < 48) {
|
||||
const len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1;
|
||||
const end = @min(line.len, i + len);
|
||||
const slice = line[i..end];
|
||||
if (slice.len == 1 and (slice[0] <= 0x20 or slice[0] == ':' or slice[0] == '|')) {
|
||||
try out.append(allocator, '_');
|
||||
} else {
|
||||
try out.appendSlice(allocator, slice);
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
if (out.items.len == 0) try out.append(allocator, '_');
|
||||
return out.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn byteOffsetForLineColumn(content_bytes: []const u8, line_no: usize, column: usize) !usize {
|
||||
var current_line: usize = 1;
|
||||
var line_start: usize = 0;
|
||||
var i: usize = 0;
|
||||
while (i <= content_bytes.len) : (i += 1) {
|
||||
if (i == content_bytes.len or content_bytes[i] == '\n') {
|
||||
if (current_line == line_no) {
|
||||
const line = content_bytes[line_start..i];
|
||||
if (column - 1 > line.len) return Error.InvalidSearchRow;
|
||||
return line_start + column - 1;
|
||||
}
|
||||
current_line += 1;
|
||||
line_start = i + 1;
|
||||
}
|
||||
}
|
||||
return Error.InvalidSearchRow;
|
||||
}
|
||||
|
||||
fn matchesPattern(pattern: []const u8, path: []const u8) bool {
|
||||
if (std.mem.endsWith(u8, pattern, "/")) return std.mem.startsWith(u8, path, pattern);
|
||||
if (std.mem.startsWith(u8, pattern, "*.")) return std.mem.endsWith(u8, path, pattern[1..]);
|
||||
@@ -124,6 +214,11 @@ fn freeItems(allocator: std.mem.Allocator, items: [][]const u8) void {
|
||||
allocator.free(items);
|
||||
}
|
||||
|
||||
fn freeOwnedItems(allocator: std.mem.Allocator, items: [][]const u8) void {
|
||||
for (items) |item| allocator.free(item);
|
||||
allocator.free(items);
|
||||
}
|
||||
|
||||
test "regular: picker respects gitignore by default and can include ignored" {
|
||||
var repo = Index.init(std.testing.allocator);
|
||||
defer repo.deinit();
|
||||
@@ -179,3 +274,53 @@ test "adversarial: invalid paths and patterns are rejected" {
|
||||
try std.testing.expectError(Error.InvalidPattern, repo.addIgnorePattern("bad pattern"));
|
||||
try std.testing.expectError(Error.FileNotFound, repo.content("missing.zig"));
|
||||
}
|
||||
|
||||
test "regular: project search returns file line column and preview rows" {
|
||||
var repo = Index.init(std.testing.allocator);
|
||||
defer repo.deinit();
|
||||
try repo.addIgnorePattern("zig-out/");
|
||||
try repo.addFile("src/main.zig", "pub fn main() void {}\nconst needle = 1;");
|
||||
try repo.addFile("src/lib.zig", "needle here");
|
||||
try repo.addFile("zig-out/log.txt", "needle ignored");
|
||||
|
||||
const rows = try repo.searchRowsAlloc(std.testing.allocator, "needle", false);
|
||||
defer freeOwnedItems(std.testing.allocator, rows);
|
||||
try std.testing.expectEqual(@as(usize, 2), rows.len);
|
||||
try std.testing.expectEqualStrings("src/main.zig:2:7:const_needle_=_1;", rows[0]);
|
||||
try std.testing.expectEqualStrings("src/lib.zig:1:1:needle_here", rows[1]);
|
||||
}
|
||||
|
||||
test "regular: project search can include ignored files explicitly" {
|
||||
var repo = Index.init(std.testing.allocator);
|
||||
defer repo.deinit();
|
||||
try repo.addIgnorePattern("zig-out/");
|
||||
try repo.addFile("src/main.zig", "needle");
|
||||
try repo.addFile("zig-out/log.txt", "needle");
|
||||
|
||||
const rows = try repo.searchRowsAlloc(std.testing.allocator, "needle", true);
|
||||
defer freeOwnedItems(std.testing.allocator, rows);
|
||||
try std.testing.expectEqual(@as(usize, 2), rows.len);
|
||||
}
|
||||
|
||||
test "regular: search row resolves back to file and byte offset" {
|
||||
var repo = Index.init(std.testing.allocator);
|
||||
defer repo.deinit();
|
||||
try repo.addFile("src/main.zig", "abc\nlet needle = 1;\n");
|
||||
|
||||
const rows = try repo.searchRowsAlloc(std.testing.allocator, "needle", false);
|
||||
defer freeOwnedItems(std.testing.allocator, rows);
|
||||
const resolved = try repo.searchOffsetFromRow(rows[0]);
|
||||
try std.testing.expectEqualStrings("src/main.zig", resolved.path);
|
||||
try std.testing.expectEqual(@as(usize, 8), resolved.offset);
|
||||
}
|
||||
|
||||
test "adversarial: project search rejects bad queries and rows" {
|
||||
var repo = Index.init(std.testing.allocator);
|
||||
defer repo.deinit();
|
||||
try repo.addFile("src/main.zig", "needle");
|
||||
|
||||
try std.testing.expectError(Error.InvalidQuery, repo.searchRowsAlloc(std.testing.allocator, "", false));
|
||||
try std.testing.expectError(Error.InvalidQuery, repo.searchRowsAlloc(std.testing.allocator, "two words", false));
|
||||
try std.testing.expectError(Error.InvalidSearchRow, repo.searchOffsetFromRow("bad-row"));
|
||||
try std.testing.expectError(Error.FileNotFound, repo.searchOffsetFromRow("missing.zig:1:1:needle"));
|
||||
}
|
||||
|
||||
+8
-1
@@ -138,8 +138,15 @@ pub const Session = struct {
|
||||
}
|
||||
|
||||
pub fn openFixture(self: *Session, fixture: []const u8) !void {
|
||||
try self.openFixtureAt(fixture, 0);
|
||||
}
|
||||
|
||||
pub fn openFixtureAt(self: *Session, fixture: []const u8, cursor_byte: usize) !void {
|
||||
if (self.buffer) |*buffer| buffer.deinit();
|
||||
self.buffer = try Buffer.openFromBytes(self.allocator, fixture);
|
||||
var buffer = try Buffer.openFromBytes(self.allocator, fixture);
|
||||
buffer.cursor.byte = previousBoundary(buffer.bytes.items, @min(cursor_byte, buffer.bytes.items.len));
|
||||
buffer.refreshCell();
|
||||
self.buffer = buffer;
|
||||
}
|
||||
|
||||
pub fn dispatch(self: *Session, command: Command) !void {
|
||||
|
||||
+86
@@ -125,6 +125,9 @@ pub const Client = struct {
|
||||
if (std.mem.eql(u8, line, "file_tree")) return self.openRepoList("tree", true, false);
|
||||
if (std.mem.eql(u8, line, "file_tree all")) return self.openRepoList("tree", true, true);
|
||||
if (std.mem.eql(u8, line, "file_open_selected")) return self.openSelectedRepoFile();
|
||||
if (std.mem.startsWith(u8, line, "search_text all ")) return self.openSearchList(line[16..], true);
|
||||
if (std.mem.startsWith(u8, line, "search_text ")) return self.openSearchList(line[12..], false);
|
||||
if (std.mem.eql(u8, line, "search_open_selected")) return self.openSelectedSearchResult();
|
||||
if (std.mem.startsWith(u8, line, "panel_open ")) return self.applyProtocolCommand(line);
|
||||
if (std.mem.startsWith(u8, line, "list_open ")) return self.applyProtocolCommand(line);
|
||||
if (std.mem.startsWith(u8, line, "list_filter ")) return self.applyProtocolCommand(line);
|
||||
@@ -307,6 +310,25 @@ pub const Client = struct {
|
||||
self.message = null;
|
||||
}
|
||||
|
||||
fn openSearchList(self: *Client, query: []const u8, include_ignored: bool) !void {
|
||||
const rows = self.repo.searchRowsAlloc(self.allocator, query, include_ignored) catch return Error.ProtocolRejected;
|
||||
defer {
|
||||
for (rows) |row| self.allocator.free(row);
|
||||
self.allocator.free(rows);
|
||||
}
|
||||
try self.session.openListPanel("search", rows);
|
||||
self.message = null;
|
||||
}
|
||||
|
||||
fn openSelectedSearchResult(self: *Client) !void {
|
||||
const selected = self.session.selectListPanel() catch return Error.ProtocolRejected;
|
||||
const match = self.repo.searchOffsetFromRow(selected) catch return Error.ProtocolRejected;
|
||||
const content = self.repo.content(match.path) catch return Error.ProtocolRejected;
|
||||
try self.session.openFixtureAt(content, match.offset);
|
||||
try self.session.closePanel();
|
||||
self.message = null;
|
||||
}
|
||||
|
||||
fn handleTraceKey(self: *Client, key_name: []const u8) !void {
|
||||
if (std.mem.eql(u8, key_name, "space")) return self.handleInput(" ");
|
||||
if (std.mem.eql(u8, key_name, "enter")) return self.handleInput("\r");
|
||||
@@ -1032,3 +1054,67 @@ test "adversarial: invalid repo paths and missing selections fail without corrup
|
||||
defer std.testing.allocator.free(frame);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "safe") != null);
|
||||
}
|
||||
|
||||
test "regular: project text search lists matches filters results and jumps to match" {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
|
||||
defer client.deinit();
|
||||
|
||||
try client.handleTraceLine("repo_file src/main.zig=abc_needle");
|
||||
try client.handleTraceLine("repo_file src/lib.zig=needle_lib");
|
||||
try client.handleTraceLine("search_text needle");
|
||||
{
|
||||
const frame = try client.render(std.testing.allocator);
|
||||
defer std.testing.allocator.free(frame);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig:1:5") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "src/lib.zig:1:1") != null);
|
||||
}
|
||||
|
||||
try client.handleTraceLine("list_filter lib");
|
||||
{
|
||||
const frame = try client.render(std.testing.allocator);
|
||||
defer std.testing.allocator.free(frame);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "src/lib.zig:1:1") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") == null);
|
||||
}
|
||||
|
||||
try client.handleTraceLine("search_open_selected");
|
||||
const snap = try client.session.snapshot();
|
||||
try std.testing.expectEqualStrings("needle_lib", snap.bytes);
|
||||
try std.testing.expectEqual(@as(usize, 0), snap.cursor_byte);
|
||||
}
|
||||
|
||||
test "regular: project text search respects ignored files unless include ignored is explicit" {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 5 });
|
||||
defer client.deinit();
|
||||
|
||||
try client.handleTraceLine("repo_gitignore zig-out/");
|
||||
try client.handleTraceLine("repo_file src/main.zig=needle");
|
||||
try client.handleTraceLine("repo_file zig-out/log.txt=needle");
|
||||
try client.handleTraceLine("search_text needle");
|
||||
{
|
||||
const frame = try client.render(std.testing.allocator);
|
||||
defer std.testing.allocator.free(frame);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "zig-out") == null);
|
||||
}
|
||||
|
||||
try client.handleTraceLine("panel_close");
|
||||
try client.handleTraceLine("search_text all needle");
|
||||
{
|
||||
const frame = try client.render(std.testing.allocator);
|
||||
defer std.testing.allocator.free(frame);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "zig-out/log.txt") != null);
|
||||
}
|
||||
}
|
||||
|
||||
test "adversarial: project text search failures do not corrupt current buffer" {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 5 });
|
||||
defer client.deinit();
|
||||
|
||||
try client.handleTraceLine("open safe");
|
||||
try client.handleTraceLine("repo_file src/main.zig=needle");
|
||||
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("search_text two words"));
|
||||
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("search_open_selected"));
|
||||
const snap = try client.session.snapshot();
|
||||
try std.testing.expectEqualStrings("safe", snap.bytes);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user