1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
|
const std = @import("std");
const oni = @import("oniguruma");
/// Default URL/path regex. This is used to detect URLs and file paths in
/// terminal output.
///
/// This is here in the config package because one day the matchers will be
/// configurable and this will be a default.
///
/// For scheme URLs, this regex is liberal in what it accepts after the scheme,
/// with exceptions for URLs ending with . or ). Although such URLs are
/// perfectly valid, it is common for text to contain URLs surrounded by
/// parentheses (such as in Markdown links) or at the end of sentences.
/// Therefore, this regex excludes them as follows:
///
/// 1. Do not match regexes ending with .
/// 2. Do not match regexes ending with ), except for ones which contain a (
/// without a subsequent )
///
/// Rule 2 means that we handle the following two cases:
///
/// "https://en.wikipedia.org/wiki/Rust_(video_game)" (include parens)
/// "(https://example.com)" (do not include the parens)
///
/// There are many complicated cases where these heuristics break down, but
/// handling them well requires a non-regex approach.
const url_schemes =
\\https?://|mailto:|ftp://|file:|ssh:|git://|ssh://|tel:|magnet:|ipfs://|ipns://|gemini://|gopher://|news:
;
const ipv6_url_pattern =
\\(?:\[[:0-9a-fA-F]+(?:[:0-9a-fA-F]*)+\](?::[0-9]+)?)
;
const scheme_url_chars =
\\[\w\-.~:/?#@!$&*+,;=%]
;
const path_chars =
\\[\w\-.~:\/?#@!$&*+;=%]
;
const optional_bracketed_word_suffix =
\\(?:[\(\[]\w*[\)\]])?
;
const no_trailing_punctuation =
\\(?<![,.])
;
const no_trailing_colon =
\\(?<!:)
;
const dotted_path_lookahead =
\\(?=[\w\-.~:\/?#@!$&*+;=%]*\.)
;
const non_dotted_path_lookahead =
\\(?![\w\-.~:\/?#@!$&*+;=%]*\.)
;
const dotted_path_space_segments =
\\(?:(?<!:) (?!\w+:\/\/)(?!\.{0,2}\/)(?!~\/)[\w\-.~:\/?#@!$&*+;=%]*[\/.])*
;
const any_path_space_segments =
\\(?:(?<!:) (?!\w+:\/\/)(?!\.{0,2}\/)(?!~\/)[\w\-.~:\/?#@!$&*+;=%]+)*
;
// Branch 1: URLs with explicit schemes (http, mailto, ftp, etc.).
const scheme_url_branch =
"(?:" ++ url_schemes ++ ")" ++
"(?:" ++ ipv6_url_pattern ++ "|" ++ scheme_url_chars ++ "+" ++ optional_bracketed_word_suffix ++ ")+" ++
no_trailing_punctuation;
const rooted_or_relative_path_prefix =
\\(?:\.\.\/|\.\/|(?<!\w)~\/|(?:[\w][\w\-.]*\/)*(?<!\w)\$[A-Za-z_]\w*\/|\.[\w][\w\-.]*\/|(?<![\w~\/])\/(?!\/))
;
// Branch 2: Absolute paths and dot-relative paths (/, ./, ../).
// A dotted segment is treated as file-like, while the undotted case stays
// broad to capture directory-like paths with spaces.
const rooted_or_relative_path_branch =
rooted_or_relative_path_prefix ++
"(?:" ++
dotted_path_lookahead ++
path_chars ++ "+" ++
dotted_path_space_segments ++
no_trailing_colon ++
"|" ++
non_dotted_path_lookahead ++
path_chars ++ "+" ++
any_path_space_segments ++
no_trailing_colon ++
")";
// Branch 3: Bare relative paths such as src/config/url.zig.
const bare_relative_path_prefix =
\\(?<!\$\d*)(?<!\w)[\w][\w\-.]*\/
;
const bare_relative_path_branch =
dotted_path_lookahead ++
bare_relative_path_prefix ++
path_chars ++ "+" ++
no_trailing_colon;
pub const regex =
scheme_url_branch ++
"|" ++
rooted_or_relative_path_branch ++
"|" ++
bare_relative_path_branch;
test "url regex" {
const testing = std.testing;
try oni.testing.ensureInit();
var re = try oni.Regex.init(
regex,
.{},
oni.Encoding.utf8,
oni.Syntax.default,
null,
);
defer re.deinit();
// The URL cases to test what our regex matches. Feel free to add to this
// as we find bugs or just want more coverage.
const cases = [_]struct {
input: []const u8,
expect: []const u8,
num_matches: usize = 1,
}{
.{
.input = "hello https://example.com world",
.expect = "https://example.com",
},
.{
.input = "https://example.com/foo(bar) more",
.expect = "https://example.com/foo(bar)",
},
.{
.input = "https://example.com/foo(bar)baz more",
.expect = "https://example.com/foo(bar)baz",
},
.{
.input = "Link inside (https://example.com) parens",
.expect = "https://example.com",
},
.{
.input = "Link period https://example.com. More text.",
.expect = "https://example.com",
},
.{
.input = "Link trailing comma https://example.com, more text.",
.expect = "https://example.com",
},
.{
.input = "Link in double quotes \"https://example.com\" and more",
.expect = "https://example.com",
},
.{
.input = "Link in single quotes 'https://example.com' and more",
.expect = "https://example.com",
},
.{
.input = "some file with https://google.com https://duckduckgo.com links.",
.expect = "https://google.com",
},
.{
.input = "and links in it. links https://yahoo.com mailto:test@example.com ssh://1.2.3.4",
.expect = "https://yahoo.com",
},
.{
.input = "also match http://example.com non-secure links",
.expect = "http://example.com",
},
.{
.input = "match tel://+12123456789 phone numbers",
.expect = "tel://+12123456789",
},
.{
.input = "match with query url https://example.com?query=1&other=2 and more text.",
.expect = "https://example.com?query=1&other=2",
},
.{
.input = "url with dashes [mode 2027](https://github.com/contour-terminal/terminal-unicode-core) for better unicode support",
.expect = "https://github.com/contour-terminal/terminal-unicode-core",
},
.{
.input = "dot.http://example.com",
.expect = "http://example.com",
},
// weird characters in URL
.{
.input = "weird characters https://example.com/~user/?query=1&other=2#hash and more",
.expect = "https://example.com/~user/?query=1&other=2#hash",
},
// square brackets in URL
.{
.input = "square brackets https://example.com/[foo] and more",
.expect = "https://example.com/[foo]",
},
// square bracket following url
.{
.input = "[13]:TooManyStatements: TempFile#assign_temp_file_to_entity has approx 7 statements [https://example.com/docs/Too-Many-Statements.md]",
.expect = "https://example.com/docs/Too-Many-Statements.md",
},
// remaining URL schemes tests
.{
.input = "match ftp://example.com ftp links",
.expect = "ftp://example.com",
},
.{
.input = "match file://example.com file links",
.expect = "file://example.com",
},
.{
.input = "match ssh://example.com ssh links",
.expect = "ssh://example.com",
},
.{
.input = "match git://example.com git links",
.expect = "git://example.com",
},
.{
.input = "/tmp/test.txt http://www.google.com",
.expect = "/tmp/test.txt",
},
.{
.input = "match tel:+18005551234 tel links",
.expect = "tel:+18005551234",
},
.{
.input = "match magnet:?xt=urn:btih:1234567890 magnet links",
.expect = "magnet:?xt=urn:btih:1234567890",
},
.{
.input = "match ipfs://QmSomeHashValue ipfs links",
.expect = "ipfs://QmSomeHashValue",
},
.{
.input = "match ipns://QmSomeHashValue ipns links",
.expect = "ipns://QmSomeHashValue",
},
.{
.input = "match gemini://example.com gemini links",
.expect = "gemini://example.com",
},
.{
.input = "match gopher://example.com gopher links",
.expect = "gopher://example.com",
},
.{
.input = "match news:comp.infosystems.www.servers.unix news links",
.expect = "news:comp.infosystems.www.servers.unix",
},
.{
.input = "/Users/ghostty.user/code/example.py",
.expect = "/Users/ghostty.user/code/example.py",
},
.{
.input = "/Users/ghostty.user/code/../example.py",
.expect = "/Users/ghostty.user/code/../example.py",
},
.{
.input = "/Users/ghostty.user/code/../example.py hello world",
.expect = "/Users/ghostty.user/code/../example.py",
},
.{
.input = "../example.py",
.expect = "../example.py",
},
.{
.input = "../example.py ",
.expect = "../example.py",
},
.{
.input = "first time ../example.py contributor ",
.expect = "../example.py",
},
.{
.input = "[link](/home/user/ghostty.user/example)",
.expect = "/home/user/ghostty.user/example",
},
// IPv6 URL tests - Basic tests
.{
.input = "Serving HTTP on :: port 8000 (http://[::]:8000/)",
.expect = "http://[::]:8000/",
},
.{
.input = "IPv6 address https://[2001:db8::1]:8080/path",
.expect = "https://[2001:db8::1]:8080/path",
},
.{
.input = "IPv6 localhost http://[::1]:3000",
.expect = "http://[::1]:3000",
},
.{
.input = "Complex IPv6 https://[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443/",
.expect = "https://[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443/",
},
// IPv6 URL tests - URLs with paths and query parameters
.{
.input = "IPv6 with path https://[2001:db8::1]/path/to/resource",
.expect = "https://[2001:db8::1]/path/to/resource",
},
.{
.input = "IPv6 with query https://[2001:db8::1]:8080/api?param=value&other=123",
.expect = "https://[2001:db8::1]:8080/api?param=value&other=123",
},
// IPv6 URL tests - Compressed forms
.{
.input = "IPv6 compressed http://[2001:db8::]:80/",
.expect = "http://[2001:db8::]:80/",
},
.{
.input = "IPv6 multiple zeros http://[2001:0:0:0:0:0:0:1]",
.expect = "http://[2001:0:0:0:0:0:0:1]",
},
// IPv6 URL tests - Special cases
.{
.input = "IPv6 link-local https://[fe80::1234:5678:9abc]",
.expect = "https://[fe80::1234:5678:9abc]",
},
.{
.input = "IPv6 multicast http://[ff02::1]/stream",
.expect = "http://[ff02::1]/stream",
},
// IPv6 URL tests - Mixed scenarios
.{
.input = "IPv6 in markdown [link](http://[2001:db8::1]/docs)",
.expect = "http://[2001:db8::1]/docs",
},
// Trailing whitespace isn't part of a detected file path.
.{
.input = "./spaces-end. ",
.expect = "./spaces-end.",
},
// File paths with internal spaces
.{
.input = "./space middle",
.expect = "./space middle",
},
.{
.input = "../test folder/file.txt",
.expect = "../test folder/file.txt",
},
.{
.input = "/tmp/test folder/file.txt",
.expect = "/tmp/test folder/file.txt",
},
.{
.input = "/tmp/test folder/file.txt",
.expect = "/tmp/test",
},
// unified diff lines
.{
.input = "diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig",
.expect = "a/src/font/shaper/harfbuzz.zig",
},
// Two space-separated absolute paths should match only the first
.{
.input = "/tmp/foo /tmp/bar",
.expect = "/tmp/foo",
},
.{
.input = "/tmp/foo.txt /tmp/bar.txt",
.expect = "/tmp/foo.txt",
},
// Bare relative file paths (no ./ or ../ prefix)
.{
.input = "src/config/url.zig",
.expect = "src/config/url.zig",
},
.{
.input = "app/folder/file.rb:1",
.expect = "app/folder/file.rb:1",
},
.{
.input = "modified: src/config/url.zig",
.expect = "src/config/url.zig",
},
.{
.input = "lib/ghostty/terminal.zig:42:10",
.expect = "lib/ghostty/terminal.zig:42:10",
},
.{
.input = "some-pkg/src/file.txt more text",
.expect = "some-pkg/src/file.txt",
},
// comma should match substrings
.{
.input = "src/foo.c,baz.txt",
.expect = "src/foo.c",
},
.{
.input = "~/foo/bar.txt",
.expect = "~/foo/bar.txt",
},
.{
.input = "open ~/Documents/notes.md please",
.expect = "~/Documents/notes.md",
},
.{
.input = "~/.config/ghostty/config",
.expect = "~/.config/ghostty/config",
},
.{
.input = "directory: ~/src/ghostty-org/ghostty",
.expect = "~/src/ghostty-org/ghostty",
},
.{
.input = "$HOME/src/config/url.zig",
.expect = "$HOME/src/config/url.zig",
},
.{
.input = "project dir: $PWD/src/ghostty/main.zig",
.expect = "$PWD/src/ghostty/main.zig",
},
// $VAR mid-path should match fully, not partially from the $
.{
.input = "foo/$BAR/baz",
.expect = "foo/$BAR/baz",
},
.{
.input = ".foo/bar/$VAR",
.expect = ".foo/bar/$VAR",
},
.{
.input = ".config/ghostty/config",
.expect = ".config/ghostty/config",
},
.{
.input = "loaded from .local/share/ghostty/state.db now",
.expect = ".local/share/ghostty/state.db",
},
.{
.input = "../some/where",
.expect = "../some/where",
},
// comma-separated file paths
.{
.input = " - shared/src/foo/SomeItem.m:12, shared/src/",
.expect = "shared/src/foo/SomeItem.m:12",
},
// mid-string dot should not partially match but fully
.{
.input = "foo.local/share",
.expect = "foo.local/share",
},
// numeric directory should match fully
.{
.input = "2024/report.txt",
.expect = "2024/report.txt",
},
// comma should stop matching in spaced path segments
.{
.input = "./foo bar,baz",
.expect = "./foo bar",
},
.{
.input = "/tmp/foo bar,baz",
.expect = "/tmp/foo bar",
},
// trailing colon should not be part of the path
.{
.input = "./.config/ghostty: Needs upstream (main)",
.expect = "./.config/ghostty",
},
.{
.input = "./Downloads: Operation not permitted",
.expect = "./Downloads",
},
};
for (cases) |case| {
//std.debug.print("input: {s}\n", .{case.input});
//std.debug.print("match: {s}\n", .{case.expect});
var reg = try re.search(case.input, .{});
//std.debug.print("count: {d}\n", .{@as(usize, reg.count())});
//std.debug.print("starts: {d}\n", .{reg.starts()});
//std.debug.print("ends: {d}\n", .{reg.ends()});
defer reg.deinit();
try testing.expectEqual(@as(usize, case.num_matches), reg.count());
const match = case.input[@intCast(reg.starts()[0])..@intCast(reg.ends()[0])];
try testing.expectEqualStrings(case.expect, match);
}
const no_match_cases = [_][]const u8{
// bare relative paths without any dot should not match as file paths
"input/output",
"foo/bar",
// $-numeric character should not match
"$10/bar",
"$10/$20",
"$10/bar.txt",
// comma should not let dot detection look past it
"foo/bar,baz.txt",
// $VAR should not match mid-word
"foo$BAR/baz.txt",
// ~ should not match mid-word
"foo~/bar.txt",
// double-slash comments are not paths
"// foo bar",
"//foo",
};
for (no_match_cases) |input| {
var result = re.search(input, .{});
if (result) |*reg| {
reg.deinit();
return error.TestUnexpectedResult;
} else |_| {}
}
}
|