Add project text search panel
This commit is contained in:
+145
@@ -10,6 +10,8 @@ test {
|
|||||||
pub const Error = error{
|
pub const Error = error{
|
||||||
InvalidPath,
|
InvalidPath,
|
||||||
InvalidPattern,
|
InvalidPattern,
|
||||||
|
InvalidQuery,
|
||||||
|
InvalidSearchRow,
|
||||||
FileNotFound,
|
FileNotFound,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,6 +86,33 @@ pub const Index = struct {
|
|||||||
return Error.FileNotFound;
|
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 {
|
pub fn isIgnored(self: *const Index, path: []const u8) bool {
|
||||||
for (self.ignore_patterns.items) |pattern| {
|
for (self.ignore_patterns.items) |pattern| {
|
||||||
if (matchesPattern(pattern, path)) return true;
|
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 {
|
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.endsWith(u8, pattern, "/")) return std.mem.startsWith(u8, path, pattern);
|
||||||
if (std.mem.startsWith(u8, pattern, "*.")) return std.mem.endsWith(u8, path, pattern[1..]);
|
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);
|
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" {
|
test "regular: picker respects gitignore by default and can include ignored" {
|
||||||
var repo = Index.init(std.testing.allocator);
|
var repo = Index.init(std.testing.allocator);
|
||||||
defer repo.deinit();
|
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.InvalidPattern, repo.addIgnorePattern("bad pattern"));
|
||||||
try std.testing.expectError(Error.FileNotFound, repo.content("missing.zig"));
|
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 {
|
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();
|
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 {
|
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")) 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_tree all")) return self.openRepoList("tree", true, true);
|
||||||
if (std.mem.eql(u8, line, "file_open_selected")) return self.openSelectedRepoFile();
|
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, "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_open ")) return self.applyProtocolCommand(line);
|
||||||
if (std.mem.startsWith(u8, line, "list_filter ")) 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;
|
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 {
|
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, "space")) return self.handleInput(" ");
|
||||||
if (std.mem.eql(u8, key_name, "enter")) return self.handleInput("\r");
|
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);
|
defer std.testing.allocator.free(frame);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, frame, "safe") != null);
|
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