mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-05-24 05:40:15 +00:00
Fixes #11177 Use per-search Oniguruma match params (retry_limit_in_search) in StringMap-backed link detection to avoid pathological backtracking hangs on very long lines. The units are ticks in the internal loop so its kind of opaque but this seems to still match some very long URLs. The test case in question was a 169K character line (which is now rejected).
299 lines
8.6 KiB
Zig
299 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 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)
|
|
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[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);
|
|
}
|