terminal/search: ignore empty search needles

Low-level search state accepted an empty needle even though the search
thread normally filters it out. SlidingWindow treated the empty string
as a zero-length match and underflowed while calculating its inclusive
end offset. Active and viewport overlap calculations could also
underflow while loading adjacent pages.

Treat an empty needle as an inactive search with no matches or history,
and saturate the viewport overlap length.
This commit is contained in:
Mitchell Hashimoto
2026-07-09 19:54:29 -07:00
parent 7e02af8798
commit 5bc6588e43
3 changed files with 31 additions and 2 deletions

View File

@@ -56,6 +56,10 @@ pub const ActiveSearch = struct {
// Clear our previous sliding window
self.window.clearAndRetainCapacity();
// An empty needle represents an inactive search and has no overlap
// or history to load.
if (self.window.needle.len == 0) return null;
// First up, add enough pages to cover the active area.
var rem: usize = list.rows;
var node_ = list.pages.last;

View File

@@ -176,6 +176,11 @@ pub const SlidingWindow = struct {
/// or `append()`. If the caller wants to retain the flattened highlight
/// then they should clone it.
pub fn next(self: *SlidingWindow) ?FlattenedHighlight {
// An empty needle represents an inactive search. Searching for it
// would otherwise produce a zero-length match, which highlight()
// cannot represent because its end offset is inclusive.
if (self.needle.len == 0) return null;
const slices = slices: {
// If we have less data then the needle then we can't possibly match
const data_len = self.data.len();
@@ -704,6 +709,25 @@ test "SlidingWindow empty on init" {
try testing.expectEqual(0, w.meta.len());
}
test "SlidingWindow empty needle has no matches" {
const testing = std.testing;
const alloc = testing.allocator;
var w: SlidingWindow = try .init(alloc, .forward, "");
defer w.deinit();
var s = try Screen.init(alloc, .{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
});
defer s.deinit();
try s.testWriteString("hello");
_ = try w.append(s.pages.pages.first.?);
try testing.expectEqual(null, w.next());
}
test "SlidingWindow single append" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@@ -134,6 +134,7 @@ pub const ViewportSearch = struct {
// Add enough overlap to cover needle.len - 1 bytes (if it
// exists) so we can cover the overlap. We only do this for the
// soft-wrapped prior pages.
const overlap_len = self.window.needle.len -| 1;
var node_ = fingerprint.nodes[0].prev;
var added: usize = 0;
while (node_) |node| : (node_ = node.prev) {
@@ -142,7 +143,7 @@ pub const ViewportSearch = struct {
// wrap so this should be fine.
const appended = try self.window.appendIfWrapped(node) orelse break;
added += appended.content_len;
if (added >= self.window.needle.len - 1) break;
if (added >= overlap_len) break;
}
// We can use our fingerprint nodes to initialize our sliding
@@ -162,7 +163,7 @@ pub const ViewportSearch = struct {
while (node_) |node| : (node_ = node.next) {
const appended = try self.window.append(node);
added += appended.content_len;
if (added >= self.window.needle.len - 1) break;
if (added >= overlap_len) break;
// If this row doesn't wrap, then we can quit
if (!appended.last_row_wrapped) break;