const std = @import("std"); const Allocator = std.mem.Allocator; const testing = std.testing; const terminal = @import("../main.zig"); const point = terminal.point; const size = terminal.size; const FlattenedHighlight = @import("../highlight.zig").Flattened; const Page = terminal.Page; const PageList = terminal.PageList; const Pin = PageList.Pin; const Selection = terminal.Selection; const Screen = terminal.Screen; const Terminal = @import("../Terminal.zig"); const SlidingWindow = @import("sliding_window.zig").SlidingWindow; /// Searches for a term in a PageList structure. /// /// This searches in reverse order starting from the given node. /// /// This assumes that nodes do not change contents. For nodes that change /// contents, look at ActiveSearch, which is designed to re-search the active /// area since it assumed to change. When integrating ActiveSearch with /// PageListSearch, the caller should start the PageListSearch from the /// returned node from ActiveSearch.update(). /// /// Concurrent access to a PageList or nodes in a PageList are not allowed, /// so the caller should ensure that necessary locks are held. Each function /// documents whether it accesses the PageList or not. For example, you can /// safely call `next()` without holding a lock, but you must hold a lock /// while calling `feed()`. pub const PageListSearch = struct { /// The list we're searching. list: *PageList, /// The sliding window of page contents and nodes to search. window: SlidingWindow, /// The tracked pin for our current position in the pagelist. This /// will always point to the CURRENT node we're searching from so that /// we can track if we move. pin: *Pin, /// Initialize the page list search. The needle is copied so it can /// be freed immediately. /// /// Accesses the PageList/Node so the caller must ensure it is safe /// to do so if there is any concurrent access. pub fn init( alloc: Allocator, needle: []const u8, list: *PageList, start: *PageList.List.Node, ) Allocator.Error!PageListSearch { // We put a tracked pin into the node that we're starting from. // By using a tracked pin, we can keep our pagelist references safe // because if the pagelist prunes pages, the tracked pin will // be moved somewhere safe. const pin = try list.trackPin(.{ .node = start, .y = start.rows() - 1, .x = start.cols() - 1, }); errdefer list.untrackPin(pin); // Create our sliding window we'll use for searching. var window: SlidingWindow = try .init(alloc, .reverse, needle); errdefer window.deinit(); // We always feed our initial page data into the window, because // we have the lock anyways and this lets our `pin` point to our // current node and feed to work properly. _ = try window.append(start); return .{ .list = list, .window = window, .pin = pin, }; } /// Modifies the PageList (to untrack a pin) so the caller must ensure /// that it is safe to do so. pub fn deinit(self: *PageListSearch) void { self.window.deinit(); self.list.untrackPin(self.pin); } /// Release owned search buffers after the PageList itself has already /// been destroyed. Its pin belonged to the PageList pool and was freed /// with that list, so it must not be untracked here. pub fn deinitListInvalid(self: *PageListSearch) void { self.window.deinit(); } /// Return the next match in the loaded page nodes. If this returns /// null then the PageList search needs to be fed the next node(s). /// Call, `feed` to do this. /// /// Beware that the selection returned may point to a node that /// is freed if the caller does not hold necessary locks on the /// PageList while searching. The pins should be validated prior to /// final use. /// /// This does NOT access the PageList, so it can be called without /// a lock held. pub fn next(self: *PageListSearch) ?FlattenedHighlight { return self.window.next(); } /// Feed more data to the sliding window from the pagelist. This will /// feed enough data to cover at least one match (needle length) if it /// exists; this doesn't perform the search, it only feeds data. /// /// This accesses nodes in the PageList, so the caller must ensure /// it is safe to do so (i.e. hold necessary locks). /// /// This returns false if there is no more data to feed. This essentially /// means we've searched the entire pagelist. pub fn feed(self: *PageListSearch) Allocator.Error!bool { // If our pin becomes garbage it means wherever we were next // was reused and we can't make sense of our progress anymore. // It is effectively equivalent to reaching the end of the PageList. if (self.pin.garbage) return false; // Add at least enough data to find a single match. var rem = self.window.needle.len; // Start at our previous node and then continue adding until we // get our desired amount of data. var node_: ?*PageList.List.Node = self.pin.node.prev; while (node_) |node| : (node_ = node.prev) { rem -|= (try self.window.append(node)).content_len; // Move our tracked pin to the new node. self.pin.node = node; self.pin.y = node.rows() - 1; self.pin.x = node.cols() - 1; self.pin.garbage = false; if (rem == 0) break; } // True if we fed any data. return rem < self.window.needle.len; } }; test "simple search" { const alloc = testing.allocator; const io = testing.io; var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); var s = t.vtStream(); defer s.deinit(); s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: PageListSearch = try .init( alloc, "Fizz", &t.screens.active.pages, t.screens.active.pages.pages.last.?, ); defer search.deinit(); { const h = search.next().?; const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 2, } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } { const h = search.next().?; const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 0, } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); // We should not be able to feed since we have one page try testing.expect(!try search.feed()); } test "feed multiple pages with matches" { const alloc = testing.allocator; const io = testing.io; var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); var s = t.vtStream(); defer s.deinit(); // Fill up first page const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); s.nextSlice("Fizz"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Create second page s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); s.nextSlice("Buzz\r\nFizz"); var search: PageListSearch = try .init( alloc, "Fizz", &t.screens.active.pages, t.screens.active.pages.pages.last.?, ); defer search.deinit(); // First match on the last page const sel1 = search.next(); try testing.expect(sel1 != null); try testing.expect(search.next() == null); // Feed should succeed and load the first page try testing.expect(try search.feed()); // Now we should find the match on the first page const sel2 = search.next(); try testing.expect(sel2 != null); try testing.expect(search.next() == null); // No more pages to feed try testing.expect(!try search.feed()); } test "feed multiple pages no matches" { const alloc = testing.allocator; const io = testing.io; var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); var s = t.vtStream(); defer s.deinit(); // Fill up first page const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); s.nextSlice("Hello"); // Create second page s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); s.nextSlice("World"); var search: PageListSearch = try .init( alloc, "Nope", &t.screens.active.pages, t.screens.active.pages.pages.last.?, ); defer search.deinit(); // No matches on last page try testing.expect(search.next() == null); // Feed first page try testing.expect(try search.feed()); // Still no matches try testing.expect(search.next() == null); // No more pages try testing.expect(!try search.feed()); } test "feed iteratively through multiple matches" { const alloc = testing.allocator; const io = testing.io; var t: Terminal = try .init(io, alloc, .{ .cols = 80, .rows = 24 }); defer t.deinit(alloc); var s = t.vtStream(); defer s.deinit(); const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; // Fill first page with a match at the end for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); s.nextSlice("Page1Test"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Create second page with a match s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); s.nextSlice("Page2Test"); var search: PageListSearch = try .init( alloc, "Test", &t.screens.active.pages, t.screens.active.pages.pages.last.?, ); defer search.deinit(); // Match on page 2 try testing.expect(search.next() != null); try testing.expect(search.next() == null); // Feed page 1 try testing.expect(try search.feed()); try testing.expect(search.next() != null); try testing.expect(search.next() == null); // No more pages try testing.expect(!try search.feed()); } test "feed with match spanning page boundary" { const alloc = testing.allocator; const io = testing.io; var t: Terminal = try .init(io, alloc, .{ .cols = 80, .rows = 24 }); defer t.deinit(alloc); var s = t.vtStream(); defer s.deinit(); const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; // Fill first page ending with "Te" for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); for (0..t.screens.active.pages.cols - 2) |_| s.nextSlice("x"); s.nextSlice("Te"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Second page starts with "st" s.nextSlice("st"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); var search: PageListSearch = try .init( alloc, "Test", &t.screens.active.pages, t.screens.active.pages.pages.last.?, ); defer search.deinit(); // No complete match on last page alone (only has "st") try testing.expect(search.next() == null); // Feed first page - this should give us enough data to find "Test" try testing.expect(try search.feed()); // Should find the spanning match const h = search.next().?; const sel = h.untracked(); try testing.expect(sel.start.node != sel.end.node); { const str = try t.screens.active.selectionString( alloc, .{ .sel = .init(sel.start, sel.end, false) }, ); defer alloc.free(str); try testing.expectEqualStrings(str, "Test"); } // No more matches try testing.expect(search.next() == null); // No more pages try testing.expect(!try search.feed()); } test "compressed history match spanning page boundary remains compressed" { const alloc = testing.allocator; const io = testing.io; var t: Terminal = try .init(io, alloc, .{ .cols = 80, .rows = 24, .max_scrollback_bytes = 10 * 1024 * 1024, }); defer t.deinit(alloc); var stream = t.vtStream(); defer stream.deinit(); const pages = &t.screens.active.pages; const first = pages.pages.first.?; const first_page_rows = first.capacity().rows; // Put half the needle on either side of a soft-wrapped page boundary. for (0..first_page_rows - 1) |_| stream.nextSlice("\r\n"); for (0..pages.cols - 2) |_| stream.nextSlice("x"); stream.nextSlice("Test"); const second = first.next.?; try testing.expectEqual(second, pages.pages.last.?); // Move both matching pages completely into history. The third page holds // the active area, making the first two eligible for compression. while (pages.pages.last.? == second) stream.nextSlice("\r\n"); for (0..pages.rows) |_| stream.nextSlice("\r\n"); try testing.expect(pages.getTopLeft(.active).node != second); _ = pages.compress(.full); try testing.expectEqual(PageList.List.Node.Storage.compressed, first.storage()); try testing.expectEqual(PageList.List.Node.Storage.compressed, second.storage()); const compressed = pages.memoryStats(); try testing.expect(compressed.compressed_pages >= 2); var search: PageListSearch = try .init( alloc, "Test", pages, pages.pages.last.?, ); defer search.deinit(); // Drain and feed incrementally until the boundary match is available. The // exact number of feeds depends on how much blank-page text the formatter // trims, so bound progress by the number of pages rather than encoding // that implementation detail in the test. var found: ?FlattenedHighlight = null; for (0..pages.totalPages() + 1) |_| { if (search.next()) |match| { found = match; break; } if (!try search.feed()) break; } try testing.expect(found != null); const match = found.?.untracked(); try testing.expectEqual(first, match.start.node); try testing.expectEqual(second, match.end.node); try testing.expectEqual(@as(size.CellCountInt, pages.cols - 2), match.start.x); try testing.expectEqual(@as(size.CellCountInt, 1), match.end.x); // Search owns formatted text and coordinate maps, not page snapshots. The // original raw mappings therefore remain discarded after matching. try testing.expectEqual(compressed, pages.memoryStats()); try testing.expectEqual(PageList.List.Node.Storage.compressed, first.storage()); try testing.expectEqual(PageList.List.Node.Storage.compressed, second.storage()); } test "feed with match spanning page boundary with newline" { const alloc = testing.allocator; const io = testing.io; var t: Terminal = try .init(io, alloc, .{ .cols = 80, .rows = 24 }); defer t.deinit(alloc); var s = t.vtStream(); defer s.deinit(); const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; // Fill first page ending with "Te" for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); for (0..t.screens.active.pages.cols - 2) |_| s.nextSlice("x"); s.nextSlice("Te"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Second page starts with "st" s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); s.nextSlice("st"); var search: PageListSearch = try .init( alloc, "Test", &t.screens.active.pages, t.screens.active.pages.pages.last.?, ); defer search.deinit(); // Should not find any matches since we broke with an explicit newline. try testing.expect(search.next() == null); try testing.expect(try search.feed()); try testing.expect(search.next() == null); try testing.expect(!try search.feed()); } test "feed with pruned page" { const alloc = testing.allocator; // Zero here forces minimum max size to effectively two pages. var p: PageList = try .init(alloc, .{ .cols = 80, .rows = 24, .max_size = 0, }); defer p.deinit(); // Grow to capacity const page1_node = p.pages.last.?; const page1 = page1_node.page(); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try p.grow() == null); } // Grow and allocate one more page. Then fill that page up. const page2_node = (try p.grow()).?; const page2 = page2_node.page(); for (0..page2.capacity.rows - page2.size.rows) |_| { try testing.expect(try p.grow() == null); } // Setup search and feed until we can't var search: PageListSearch = try .init( alloc, "Test", &p, p.pages.last.?, ); defer search.deinit(); try testing.expect(try search.feed()); try testing.expect(!try search.feed()); // Next should create a new page, but it should reuse our first // page since we're at max size. const new = (try p.grow()).?; try testing.expect(p.pages.last.? == new); // Our first should now be page2 and our last should be page1 try testing.expectEqual(page2_node, p.pages.first.?); try testing.expectEqual(page1_node, p.pages.last.?); // Feed should still do nothing try testing.expect(!try search.feed()); } test "feed keeps its tracked pin within a shorter page" { const alloc = testing.allocator; var pages: PageList = try .init(alloc, .{ .cols = 10, .rows = 2, }); defer pages.deinit(); const first = pages.pages.first.?; while (first.rows() < first.capacity().rows) _ = try pages.grow(); const second = (try pages.grow()).?; while (second.rows() < second.capacity().rows) _ = try pages.grow(); try pages.split(.{ .node = first, .y = 1, .x = 0 }); const shorter = first.next.?; try testing.expect(shorter.rows() < second.rows()); var search: PageListSearch = try .init( alloc, "x", &pages, second, ); defer search.deinit(); try testing.expect(try search.feed()); try testing.expectEqual(shorter, search.pin.node); try testing.expect(pages.pinIsValid(search.pin.*)); }