Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299/// A string along with the mapping of each individual byte in the string/// to the point in the screen.const StringMap = @This();
const std = @import("std");const build_options = @import("terminal_options");const oni = @import("oniguruma");const point = @import("point.zig");const Selection = @import("Selection.zig");const Screen = @import("Screen.zig");const Pin = @import("PageList.zig").Pin;const Allocator = std.mem.Allocator;
// Retry budget for StringMap regex searches.//// Units are Oniguruma retry steps (internal backtracking/retry counter),// not bytes/characters/time.const oni_search_retry_limit = 100_000;
string: [:0]const u8,map: []Pin,
pub fn deinit(self: StringMap, alloc: Allocator) void { alloc.free(self.string); alloc.free(self.map);}
/// Returns an iterator that yields the next match of the given regex./// Requires Ghostty to be compiled with regex support.pub const searchIterator = if (build_options.oniguruma) searchIteratorOnielse void;
fn searchIteratorOni( self: StringMap, regex: oni.Regex,) SearchIterator { return .{ .map = self, .regex = regex };}
/// Iterates over the regular expression matches of the string.pub const SearchIterator = struct { map: StringMap, regex: oni.Regex, offset: usize = 0,
/// Returns the next regular expression match or null if there are /// no more matches. pub fn next(self: *SearchIterator) !?Match { if (self.offset >= self.map.string.len) return null;
// Use per-search match params so we can bound regex retry steps // (Oniguruma's internal backtracking work counter). var match_param = try oni.MatchParam.init(); defer match_param.deinit(); try match_param.setRetryLimitInSearch(oni_search_retry_limit);
var region = self.regex.searchWithParam( self.map.string[self.offset..], .{}, &match_param, ) catch |err| switch (err) { // Retry/stack-limit errors mean we hit our work budget and // aborted matching. // For iterator callers this is equivalent to "no further matches". error.Mismatch, error.RetryLimitInMatchOver, error.RetryLimitInSearchOver, error.MatchStackLimitOver, error.SubexpCallLimitInSearchOver, => { self.offset = self.map.string.len; return null; },
else => return err, }; errdefer region.deinit();
// Increment our offset by the number of bytes in the match. // We defer this so that we can return the match before // modifying the offset. const end_idx: usize = @intCast(region.ends()[0]); defer self.offset += end_idx;
return .{ .map = self.map, .offset = self.offset, .region = region, }; }};
/// A single regular expression match.pub const Match = struct { map: StringMap, offset: usize, region: oni.Region,
pub fn deinit(self: *Match) void { self.region.deinit(); }
/// Returns the selection containing the full match. pub fn selection(self: Match) Selection { const start_idx: usize = @intCast(self.region.starts()[0]); const end_idx: usize = @intCast(self.region.ends()[0] - 1); const start_pt = self.map.map[self.offset + start_idx]; const end_pt = self.map.map[self.offset + end_idx]; return .init(start_pt, end_pt, false); }};
test "StringMap searchIterator" { if (comptime !build_options.oniguruma) return error.SkipZigTest;
const testing = std.testing; const alloc = testing.allocator;
// Initialize our regex try oni.testing.ensureInit(); var re = try oni.Regex.init( "[A-B]{2}", .{}, oni.Encoding.utf8, oni.Syntax.default, null, ); defer re.deinit();
// Initialize our screen var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); const line = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 1, } }).?, }).?; var map: StringMap = undefined; const sel_str = try s.selectionString(alloc, .{ .sel = line, .trim = false, .map = &map, }); alloc.free(sel_str); defer map.deinit(alloc);
// Get our iterator var it = map.searchIterator(re); { var match = (try it.next()).?; defer match.deinit();
const sel = match.selection(); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 0, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
try testing.expect(try it.next() == null);}
test "StringMap searchIterator URL detection" { if (comptime !build_options.oniguruma) return error.SkipZigTest;
const testing = std.testing; const alloc = testing.allocator; const url = @import("../config/url.zig");
// Initialize URL regex try oni.testing.ensureInit(); var re = try oni.Regex.init( url.regex, .{}, oni.Encoding.utf8, oni.Syntax.default, null, ); defer re.deinit();
// Initialize our screen with text containing a URL var s = try Screen.init(alloc, .{ .cols = 40, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("hello https://example.com/path world");
// Get the line const line = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 10, .y = 0, } }).?, }).?; var map: StringMap = undefined; const sel_str = try s.selectionString(alloc, .{ .sel = line, .trim = false, .map = &map, }); alloc.free(sel_str); defer map.deinit(alloc);
// Search for URL match var it = map.searchIterator(re); { var match = (try it.next()).?; defer match.deinit();
const sel = match.selection(); // URL should start at x=6 ("https://example.com/path" starts after "hello ") try testing.expectEqual(point.Point{ .screen = .{ .x = 6, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); // URL should end at x=29 (end of "/path") try testing.expectEqual(point.Point{ .screen = .{ .x = 29, .y = 0, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
try testing.expect(try it.next() == null);}
test "StringMap searchIterator URL with click position" { if (comptime !build_options.oniguruma) return error.SkipZigTest;
const testing = std.testing; const alloc = testing.allocator; const url = @import("../config/url.zig");
// Initialize URL regex try oni.testing.ensureInit(); var re = try oni.Regex.init( url.regex, .{}, oni.Encoding.utf8, oni.Syntax.default, null, ); defer re.deinit();
// Initialize our screen with text containing a URL var s = try Screen.init(alloc, .{ .cols = 40, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("hello https://example.com world");
// Simulate clicking on "example" (x=14) const click_pin = s.pages.pin(.{ .active = .{ .x = 14, .y = 0, } }).?;
// Get the line const line = s.selectLine(.{ .pin = click_pin, }).?; var map: StringMap = undefined; const sel_str = try s.selectionString(alloc, .{ .sel = line, .trim = false, .map = &map, }); alloc.free(sel_str); defer map.deinit(alloc);
// Search for URL match and verify click position is within URL var it = map.searchIterator(re); var found_url = false; while (true) { var match = (try it.next()) orelse break; defer match.deinit();
const sel = match.selection(); if (sel.contains(&s, click_pin)) { found_url = true; // Verify URL bounds try testing.expectEqual(point.Point{ .screen = .{ .x = 6, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 24, .y = 0, } }, s.pages.pointFromPin(.screen, sel.end()).?); break; } } try testing.expect(found_url);}