From 32601cd79a23618d0e64fd0c73bfd47e713be1ef Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 31 Aug 2026 13:20:51 -0700 Subject: [PATCH] terminal/c: add search wrapper implementing the whole-terminal search API --- src/terminal/c/main.zig | 10 + src/terminal/c/search.zig | 844 +++++++++++++++++++++++++++++++++++ src/terminal/c/selection.zig | 12 + 3 files changed, 866 insertions(+) create mode 100644 src/terminal/c/search.zig diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 562fe40e6..45b4dc47f 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -40,6 +40,7 @@ pub const mouse_event = @import("mouse_event.zig"); pub const mouse_encode = @import("mouse_encode.zig"); pub const paste = @import("paste.zig"); pub const row = @import("row.zig"); +pub const search = @import("search.zig"); pub const sgr = @import("sgr.zig"); pub const size_report = @import("size_report.zig"); pub const snapshot = @import("snapshot.zig"); @@ -215,6 +216,14 @@ pub const selection_gesture_get_multi = selection_gesture.get_multi; pub const selection_gesture_event_new = selection_gesture.event_new; pub const selection_gesture_event_free = selection_gesture.event_free; pub const selection_gesture_event_set = selection_gesture.event_set; +pub const search_new = search.new; +pub const search_free = search.free; +pub const search_tick = search.tick; +pub const search_feed = search.feed; +pub const search_run = search.run; +pub const search_set = search.set; +pub const search_get = search.get; +pub const search_get_multi = search.get_multi; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; @@ -271,6 +280,7 @@ test { _ = mouse_event; _ = mouse_encode; _ = paste; + _ = search; _ = sgr; _ = size_report; _ = snapshot; diff --git a/src/terminal/c/search.zig b/src/terminal/c/search.zig new file mode 100644 index 000000000..9f66c7395 --- /dev/null +++ b/src/terminal/c/search.zig @@ -0,0 +1,844 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const searchpkg = @import("../search.zig"); +const TerminalSearch = searchpkg.Terminal; +const FlattenedHighlight = @import("../highlight.zig").Flattened; +const selection_c = @import("selection.zig"); +const terminal_c = @import("terminal.zig"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.search_c); + +/// C: GhosttySearch +pub const Search = ?*SearchWrapper; + +const SearchWrapper = struct { + alloc: std.mem.Allocator, + + /// The terminal this search is bound to. This is borrowed, so the + /// search never frees it and the terminal must outlive the search. + terminal: *terminal_c.ZigTerminal, + + search: TerminalSearch, + + /// The scroll policy applied by the select options. Persistent + /// across selects, set via the select_scroll option. + select_scroll: TerminalSearch.SelectScroll = .if_needed, +}; + +/// C: GhosttySearchStatus +pub const Status = enum(c_int) { + running = 0, + feed_required = 1, + complete = 2, + + fn fromZig(status: TerminalSearch.Status) Status { + return switch (status) { + .running => .running, + .feed_required => .feed_required, + .complete => .complete, + }; + } +}; + +/// C: GhosttySearchScroll +pub const Scroll = enum(c_int) { + if_needed = 0, + none = 1, + + fn toZig(self: Scroll) TerminalSearch.SelectScroll { + return switch (self) { + .if_needed => .if_needed, + .none => .none, + }; + } + + fn fromZig(scroll: TerminalSearch.SelectScroll) Scroll { + return switch (scroll) { + .if_needed => .if_needed, + .none => .none, + }; + } +}; + +/// C: GhosttySearchData +pub const Data = enum(c_int) { + status = 0, + needle = 1, + total_matches = 2, + selected_index = 3, + selected_match = 4, + matches = 5, + viewport_matches = 6, + select_scroll = 7, + + pub fn OutType(comptime self: Data) type { + return switch (self) { + .status => Status, + .needle => lib.String, + .total_matches => usize, + .selected_index => usize, + .selected_match => selection_c.CSelection, + .matches, .viewport_matches => selection_c.CSelectionBuffer, + .select_scroll => Scroll, + }; + } +}; + +/// C: GhosttySearchOption +pub const Option = enum(c_int) { + select_next = 0, + select_prev = 1, + select_scroll = 2, + + pub fn Type(comptime self: Option) type { + return switch (self) { + // The value must be NULL. Reserved for future use. + .select_next, .select_prev => void, + .select_scroll => Scroll, + }; + } +}; + +/// C: GhosttySearchOptions +pub const Options = extern struct { + size: usize = @sizeOf(Options), + needle: lib.String, +}; + +pub fn new( + alloc_: ?*const CAllocator, + out_search: ?*Search, + terminal: terminal_c.Terminal, + options: ?*const Options, +) callconv(lib.calling_conv) Result { + const out = out_search orelse return .invalid_value; + out.* = null; + + const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value; + const opts = options orelse return .invalid_value; + if (opts.size < @sizeOf(Options)) return .invalid_value; + if (opts.needle.len == 0) return .invalid_value; + if (@intFromPtr(opts.needle.ptr) == 0) return .invalid_value; + const needle = opts.needle.ptr[0..opts.needle.len]; + + const alloc = lib.alloc.default(alloc_); + const wrapper = alloc.create(SearchWrapper) catch return .out_of_memory; + const search = TerminalSearch.init(alloc, needle) catch { + alloc.destroy(wrapper); + return .out_of_memory; + }; + wrapper.* = .{ + .alloc = alloc, + .terminal = t, + .search = search, + }; + out.* = wrapper; + return .success; +} + +pub fn free(search_: Search) callconv(lib.calling_conv) void { + const wrapper = search_ orelse return; + wrapper.search.deinit(wrapper.terminal); + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +pub fn tick( + search_: Search, + out_status: ?*Status, +) callconv(lib.calling_conv) Result { + const wrapper = search_ orelse return .invalid_value; + _ = wrapper.search.tick(); + if (out_status) |out| out.* = .fromZig(wrapper.search.status()); + return .success; +} + +pub fn feed(search_: Search) callconv(lib.calling_conv) Result { + const wrapper = search_ orelse return .invalid_value; + + // The C API has no renderer cooperation to know whether the active + // area changed, so it is always re-scanned. This is correct without + // any dirty tracking and cheap because the active area search was + // built for exactly this. + wrapper.search.feed(wrapper.terminal, true); + return .success; +} + +pub fn run(search_: Search) callconv(lib.calling_conv) Result { + const wrapper = search_ orelse return .invalid_value; + + // Always start with a feed: complete only means caught up as of + // the last feed, so run doubles as the "terminal changed, catch + // up" convenience for one-shot embedders. + wrapper.search.feed(wrapper.terminal, true); + while (true) { + switch (wrapper.search.status()) { + .complete => return .success, + .feed_required => wrapper.search.feed(wrapper.terminal, true), + .running => _ = wrapper.search.tick(), + } + } +} + +pub fn set( + search_: Search, + option: Option, + value: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.enums.fromInt(Option, @intFromEnum(option)) orelse { + log.warn("search_set invalid option value={d}", .{@intFromEnum(option)}); + return .invalid_value; + }; + } + + return switch (option) { + inline else => |comptime_option| setTyped( + search_, + comptime_option, + if (value) |ptr| @ptrCast(@alignCast(ptr)) else null, + ), + }; +} + +fn setTyped( + search_: Search, + comptime option: Option, + value: ?*const option.Type(), +) Result { + const wrapper = search_ orelse return .invalid_value; + switch (option) { + .select_next, .select_prev => { + // The value is reserved for future use and must be NULL. + if (value != null) return .invalid_value; + + const selected = wrapper.search.select( + wrapper.terminal, + switch (option) { + .select_next => .next, + .select_prev => .prev, + else => comptime unreachable, + }, + wrapper.select_scroll, + ) catch return .out_of_memory; + if (!selected) return .no_value; + }, + + .select_scroll => { + const v = value orelse { + wrapper.select_scroll = .if_needed; + return .success; + }; + const scroll = std.enums.fromInt(Scroll, @intFromEnum(v.*)) orelse + return .invalid_value; + wrapper.select_scroll = scroll.toZig(); + }, + } + + return .success; +} + +pub fn get( + search_: Search, + data: Data, + value: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.enums.fromInt(Data, @intFromEnum(data)) orelse { + log.warn("search_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + const out_ptr = value orelse return .invalid_value; + return switch (data) { + inline else => |comptime_data| getTyped( + search_, + comptime_data, + @ptrCast(@alignCast(out_ptr)), + ), + }; +} + +pub fn get_multi( + search_: Search, + count: usize, + keys: ?[*]const Data, + values: ?[*]?*anyopaque, + out_written: ?*usize, +) callconv(lib.calling_conv) Result { + const k = keys orelse return .invalid_value; + const v = values orelse return .invalid_value; + + for (0..count) |i| { + const result = get(search_, k[i], v[i]); + if (result != .success) { + if (out_written) |w| w.* = i; + return result; + } + } + if (out_written) |w| w.* = count; + return .success; +} + +fn getTyped( + search_: Search, + comptime data: Data, + out: *data.OutType(), +) Result { + const wrapper = search_ orelse return .invalid_value; + const search = &wrapper.search; + + switch (data) { + .status => out.* = .fromZig(search.status()), + + .needle => out.* = .init(search.needle()), + + .total_matches => out.* = if (search.activeScreenSearch()) |ss| + ss.matchesLen() + else + 0, + + .selected_index => { + const ss = search.activeScreenSearch() orelse return .no_value; + const selected = ss.selected orelse return .no_value; + out.* = selected.idx; + }, + + .selected_match => { + const ss = search.activeScreenSearch() orelse return .no_value; + const hl = ss.selectedMatch() orelse return .no_value; + out.* = selectionFromHighlight(hl); + }, + + .matches => { + const ss = search.activeScreenSearch(); + const total = if (ss) |s| s.matchesLen() else 0; + if (out.cap < total) { + out.len = total; + return .out_of_space; + } + if (total > 0) { + const dst = (out.ptr orelse return .invalid_value)[0..total]; + for (dst, 0..) |*d, i| { + d.* = selectionFromHighlight(ss.?.matchAt(i).?); + } + } + out.len = total; + }, + + .viewport_matches => { + const matches = search.viewportMatches() catch + return .out_of_memory; + if (out.cap < matches.len) { + out.len = matches.len; + return .out_of_space; + } + if (matches.len > 0) { + const dst = (out.ptr orelse return .invalid_value)[0..matches.len]; + for (dst, matches) |*d, hl| d.* = selectionFromHighlight(hl); + } + out.len = matches.len; + }, + + .select_scroll => out.* = .fromZig(wrapper.select_scroll), + } + + return .success; +} + +fn selectionFromHighlight(hl: FlattenedHighlight) selection_c.CSelection { + const untracked = hl.untracked(); + return .{ + .start = .fromPin(untracked.start), + .end = .fromPin(untracked.end), + .rectangle = false, + }; +} + +fn testString(str: []const u8) lib.String { + return .init(str); +} + +test "search lifecycle and run to complete" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz\r\nBuzz\r\nFizz\r\nBang", 22); + + var search: Search = null; + const opts: Options = .{ .needle = testString("Fizz") }; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &search, + terminal, + &opts, + )); + defer free(search); + + // A fresh search must report feed_required, not complete. The + // internal completion check is trivially true before the search + // has seen the terminal. + var status: Status = .complete; + try testing.expectEqual(Result.success, get(search, .status, &status)); + try testing.expectEqual(Status.feed_required, status); + + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, get(search, .status, &status)); + try testing.expectEqual(Status.complete, status); + + var total: usize = 0; + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 2), total); + + // The needle is borrowed and matches what we searched for. + var needle: lib.String = undefined; + try testing.expectEqual(Result.success, get(search, .needle, &needle)); + try testing.expectEqualStrings("Fizz", needle.ptr[0..needle.len]); +} + +test "search tick reports feed required before first feed" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + var search: Search = null; + const opts: Options = .{ .needle = testString("Fizz") }; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &search, + terminal, + &opts, + )); + defer free(search); + + var status: Status = .complete; + try testing.expectEqual(Result.success, tick(search, &status)); + try testing.expectEqual(Status.feed_required, status); + + // NULL status out is allowed. + try testing.expectEqual(Result.success, tick(search, null)); +} + +test "search feed after write updates results" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz", 4); + + var search: Search = null; + const opts: Options = .{ .needle = testString("Fizz") }; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &search, + terminal, + &opts, + )); + defer free(search); + + try testing.expectEqual(Result.success, run(search)); + var total: usize = 0; + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 1), total); + + // Write another match: the caller-driven feed is the "terminal + // changed" signal. + terminal_c.vt_write(terminal, "\r\nFizz", 6); + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 2), total); +} + +test "search alt screen flip and return retains results" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz\r\nFizz", 10); + + var search: Search = null; + const opts: Options = .{ .needle = testString("Fizz") }; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &search, + terminal, + &opts, + )); + defer free(search); + + try testing.expectEqual(Result.success, run(search)); + var total: usize = 0; + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 2), total); + + // Flip to the alternate screen: reads now reflect that screen. + terminal_c.vt_write(terminal, "\x1b[?1049h", 8); + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 0), total); + + terminal_c.vt_write(terminal, "Fizz", 4); + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 1), total); + + // Return to the primary screen: results are retained and restored. + terminal_c.vt_write(terminal, "\x1b[?1049l", 8); + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 2), total); +} + +test "search select wraps in both directions" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz\r\nBuzz\r\nFizz", 16); + + var search: Search = null; + const opts: Options = .{ .needle = testString("Fizz") }; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &search, + terminal, + &opts, + )); + defer free(search); + try testing.expectEqual(Result.success, run(search)); + + // No selection yet. + var idx: usize = 999; + try testing.expectEqual(Result.no_value, get(search, .selected_index, &idx)); + + // Next: newest match first (index 0), then older (1), then wrap + // back to 0. + try testing.expectEqual(Result.success, set(search, .select_next, null)); + try testing.expectEqual(Result.success, get(search, .selected_index, &idx)); + try testing.expectEqual(@as(usize, 0), idx); + + var match: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, get(search, .selected_match, &match)); + try testing.expect(match.start.toPin() != null); + + try testing.expectEqual(Result.success, set(search, .select_next, null)); + try testing.expectEqual(Result.success, get(search, .selected_index, &idx)); + try testing.expectEqual(@as(usize, 1), idx); + + try testing.expectEqual(Result.success, set(search, .select_next, null)); + try testing.expectEqual(Result.success, get(search, .selected_index, &idx)); + try testing.expectEqual(@as(usize, 0), idx); + + // Prev: wrap backward to the oldest match. + try testing.expectEqual(Result.success, set(search, .select_prev, null)); + try testing.expectEqual(Result.success, get(search, .selected_index, &idx)); + try testing.expectEqual(@as(usize, 1), idx); +} + +test "search select with no matches returns no value" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + var search: Search = null; + const opts: Options = .{ .needle = testString("Fizz") }; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &search, + terminal, + &opts, + )); + defer free(search); + + try testing.expectEqual(Result.no_value, set(search, .select_next, null)); + try testing.expectEqual(Result.no_value, set(search, .select_prev, null)); +} + +test "search select rejects a non-null reserved value" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + var search: Search = null; + const opts: Options = .{ .needle = testString("Fizz") }; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &search, + terminal, + &opts, + )); + defer free(search); + + const bogus: c_int = 0; + try testing.expectEqual(Result.invalid_value, set(search, .select_next, &bogus)); + try testing.expectEqual(Result.invalid_value, set(search, .select_prev, &bogus)); +} + +test "search select scroll option" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + var search: Search = null; + const opts: Options = .{ .needle = testString("Fizz") }; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &search, + terminal, + &opts, + )); + defer free(search); + + var scroll: Scroll = .none; + try testing.expectEqual(Result.success, get(search, .select_scroll, &scroll)); + try testing.expectEqual(Scroll.if_needed, scroll); + + const none: Scroll = .none; + try testing.expectEqual(Result.success, set(search, .select_scroll, &none)); + try testing.expectEqual(Result.success, get(search, .select_scroll, &scroll)); + try testing.expectEqual(Scroll.none, scroll); + + // NULL resets to the default. + try testing.expectEqual(Result.success, set(search, .select_scroll, null)); + try testing.expectEqual(Result.success, get(search, .select_scroll, &scroll)); + try testing.expectEqual(Scroll.if_needed, scroll); + + // Invalid enum values are rejected. + const bogus: c_int = 42; + try testing.expectEqual(Result.invalid_value, set(search, .select_scroll, @ptrCast(&bogus))); +} + +test "search selection dropped when reset prunes results" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz\r\nBuzz", 10); + + var search: Search = null; + const opts: Options = .{ .needle = testString("Fizz") }; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &search, + terminal, + &opts, + )); + defer free(search); + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, set(search, .select_next, null)); + + // A selection survives unrelated writes... + terminal_c.vt_write(terminal, "\r\nBang", 6); + try testing.expectEqual(Result.success, run(search)); + var match: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, get(search, .selected_match, &match)); + + // ...but a full reset prunes every result, dropping the selection. + terminal_c.vt_write(terminal, "\x1bc", 2); + try testing.expectEqual(Result.success, run(search)); + var total: usize = 999; + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 0), total); + try testing.expectEqual(Result.no_value, get(search, .selected_match, &match)); +} + +test "search matches buffer semantics" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz\r\nBuzz\r\nFizz", 16); + + var search: Search = null; + const opts: Options = .{ .needle = testString("Fizz") }; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &search, + terminal, + &opts, + )); + defer free(search); + try testing.expectEqual(Result.success, run(search)); + + // NULL ptr with cap 0 queries the required capacity. + var buf: selection_c.CSelectionBuffer = .{}; + try testing.expectEqual(Result.out_of_space, get(search, .matches, &buf)); + try testing.expectEqual(@as(usize, 2), buf.len); + + // Too-small buffers report the required capacity. + var one: [1]selection_c.CSelection = undefined; + buf = .{ .ptr = &one, .cap = one.len }; + try testing.expectEqual(Result.out_of_space, get(search, .matches, &buf)); + try testing.expectEqual(@as(usize, 2), buf.len); + + // A large-enough buffer is filled newest to oldest. + var storage: [4]selection_c.CSelection = undefined; + buf = .{ .ptr = &storage, .cap = storage.len }; + try testing.expectEqual(Result.success, get(search, .matches, &buf)); + try testing.expectEqual(@as(usize, 2), buf.len); + const first = storage[0].start.toPin().?; + const second = storage[1].start.toPin().?; + try testing.expect(first.y != second.y or first.node != second.node); + + // Viewport matches use the same conventions and are cached across + // reads. + buf = .{}; + try testing.expectEqual(Result.out_of_space, get(search, .viewport_matches, &buf)); + try testing.expectEqual(@as(usize, 2), buf.len); + buf = .{ .ptr = &storage, .cap = storage.len }; + try testing.expectEqual(Result.success, get(search, .viewport_matches, &buf)); + try testing.expectEqual(@as(usize, 2), buf.len); + buf = .{ .ptr = &storage, .cap = storage.len }; + try testing.expectEqual(Result.success, get(search, .viewport_matches, &buf)); + try testing.expectEqual(@as(usize, 2), buf.len); +} + +test "search get_multi returns first failing index" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz", 4); + + var search: Search = null; + const opts: Options = .{ .needle = testString("Fizz") }; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &search, + terminal, + &opts, + )); + defer free(search); + try testing.expectEqual(Result.success, run(search)); + + // No selection: selected_index fails at index 1. + const keys = [_]Data{ .total_matches, .selected_index, .status }; + var total: usize = 0; + var idx: usize = 0; + var status: Status = .running; + var values = [_]?*anyopaque{ &total, &idx, &status }; + var written: usize = 999; + try testing.expectEqual(Result.no_value, get_multi( + search, + keys.len, + &keys, + &values, + &written, + )); + try testing.expectEqual(@as(usize, 1), written); + try testing.expectEqual(@as(usize, 1), total); + + // After selecting, the whole batch succeeds. + try testing.expectEqual(Result.success, set(search, .select_next, null)); + try testing.expectEqual(Result.success, get_multi( + search, + keys.len, + &keys, + &values, + &written, + )); + try testing.expectEqual(keys.len, written); + try testing.expectEqual(@as(usize, 0), idx); +} + +test "search new validates options" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + var search: Search = null; + + // Empty needle is invalid. + const empty: Options = .{ .needle = testString("") }; + try testing.expectEqual(Result.invalid_value, new( + &lib.alloc.test_allocator, + &search, + terminal, + &empty, + )); + try testing.expect(search == null); + + // NULL options and NULL terminal are invalid. + const opts: Options = .{ .needle = testString("Fizz") }; + try testing.expectEqual(Result.invalid_value, new( + &lib.alloc.test_allocator, + &search, + terminal, + null, + )); + try testing.expectEqual(Result.invalid_value, new( + &lib.alloc.test_allocator, + &search, + null, + &opts, + )); +} + +test "search free null" { + free(null); +} diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index 6dcfcc070..77d518d1f 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -38,6 +38,18 @@ pub const CSelection = extern struct { } }; +/// C: GhosttySelectionBuffer +/// +/// A caller-provided buffer of selections. This follows the same +/// conventions as GhosttyBuffer: ptr may be NULL with cap 0 to query +/// the required capacity, and len is set to the entries written on +/// success or to the required capacity on GHOSTTY_OUT_OF_SPACE. +pub const CSelectionBuffer = extern struct { + ptr: ?[*]CSelection = null, + cap: usize = 0, + len: usize = 0, +}; + /// C: GhosttyTerminalSelectWordOptions pub const SelectWordOptions = extern struct { size: usize = @sizeOf(SelectWordOptions),