From 5bc6588e43e280b32049a18835414688a46bdb26 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 19:54:29 -0700 Subject: [PATCH] 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. --- src/terminal/search/active.zig | 4 ++++ src/terminal/search/sliding_window.zig | 24 ++++++++++++++++++++++++ src/terminal/search/viewport.zig | 5 +++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig index aee21b3e3..b99b23579 100644 --- a/src/terminal/search/active.zig +++ b/src/terminal/search/active.zig @@ -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; diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index ea039bf3a..5c29ce03b 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -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; diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index a1458bbed..d8dbb9429 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -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;