mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-14 18:01:58 +00:00
Selection strings previously duplicated formatted text when callers requested a StringMap, even though the regex-link caller immediately freed the returned copy. Add a dedicated selectionStringMap path that transfers the formatter output and pin map directly into the returned map. This removes the extra allocation and copy while making ownership explicit.
297 lines
8.6 KiB
Zig
297 lines
8.6 KiB
Zig
/// 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 PinMap = @import("formatter.zig").PinMap;
|
|
const Selection = @import("Selection.zig");
|
|
const Screen = @import("Screen.zig");
|
|
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,
|
|
|
|
/// Mapping of string byte offsets to pins. See PinMap for the
|
|
/// storage details.
|
|
map: PinMap.Map,
|
|
|
|
pub fn deinit(self: StringMap, alloc: Allocator) void {
|
|
alloc.free(self.string);
|
|
var map = self.map;
|
|
map.deinit(alloc);
|
|
}
|
|
|
|
/// 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)
|
|
searchIteratorOni
|
|
else
|
|
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.get(self.offset + start_idx).?;
|
|
const end_pt = self.map.map.get(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;
|
|
const io = testing.io;
|
|
|
|
// 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(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 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,
|
|
} }).?,
|
|
}).?;
|
|
const map = try s.selectionStringMap(alloc, .{
|
|
.sel = line,
|
|
.trim = false,
|
|
});
|
|
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 io = testing.io;
|
|
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(io, alloc, .{ .cols = 40, .rows = 5, .max_scrollback_bytes = 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,
|
|
} }).?,
|
|
}).?;
|
|
const map = try s.selectionStringMap(alloc, .{
|
|
.sel = line,
|
|
.trim = false,
|
|
});
|
|
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 io = testing.io;
|
|
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(io, alloc, .{ .cols = 40, .rows = 5, .max_scrollback_bytes = 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,
|
|
}).?;
|
|
const map = try s.selectionStringMap(alloc, .{
|
|
.sel = line,
|
|
.trim = false,
|
|
});
|
|
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);
|
|
}
|