Fix clicking bare relative file paths

Related to #1972

The URL regex for file path detection requires paths to start with
`../`, `./`, or `/`. For bare relative paths like
`"src/config/url.zig"`, the regex could only match starting at the first
`/`, producing `"/config/url.zig"` as a result — always dropping the
first part of the path.

Fix: added a third top-level alternative to the regex. This matches bare
relative paths where:

1. The first component is word characters (possibly with dots/dashes):
   `[\w][\w\-.]*\/`
2. The remaining path must contain a dot (via positive lookahead) — this
   requires a file extension to avoid false positives on text like
   input/output
3. Add a `(?<!\w)\/` instead of `\/` in the existing prefix group — the
   standalone `/` prefix now requires that `/` is not preceded by a word
   character. This prevents `"input/output"` from falsely matching
   `"/output"`

Test cases added:
- src/config/url.zig → matches fully
- app/folder/file.rb:1 → matches with line number
- modified:   src/config/url.zig → matches only the path part
- lib/ghostty/terminal.zig:42:10 → matches with line:col
- some-pkg/src/file.txt more text → stops before trailing text
- input/output and foo/bar → correctly do not match (no file extension)

The issue was nailed down here:
https://github.com/ghostty-org/ghostty/issues/1972#issuecomment-3845717672
This commit is contained in:
Ben Kircher
2026-02-04 14:51:17 +01:00
parent 51897c0cd5
commit 6c0a17cccf

View File

@@ -26,7 +26,7 @@ pub const regex =
"(?:" ++ url_schemes ++
\\)(?:
++ ipv6_url_pattern ++
\\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(?<![,.])|(?:\.\.\/|\.\/|\/)(?:(?=[\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]*[\/.])*(?: +(?= *$))?|(?![\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]+)*(?: +(?= *$))?)
\\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(?<![,.])|(?:\.\.\/|\.\/|(?<!\w)\/)(?:(?=[\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]*[\/.])*(?: +(?= *$))?|(?![\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]+)*(?: +(?= *$))?)|[\w][\w\-.]*\/(?=[\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]*[\/.])*(?: +(?= *$))?
;
const url_schemes =
\\https?://|mailto:|ftp://|file:|ssh:|git://|ssh://|tel:|magnet:|ipfs://|ipns://|gemini://|gopher://|news:
@@ -270,6 +270,27 @@ test "url regex" {
.input = "/tmp/test folder/file.txt",
.expect = "/tmp/test folder/file.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",
},
};
for (cases) |case| {
@@ -284,4 +305,17 @@ test "url regex" {
const match = case.input[@intCast(reg.starts()[0])..@intCast(reg.ends()[0])];
try testing.expectEqualStrings(case.expect, match);
}
// Bare relative paths without any dot should not match as file paths
const no_match_cases = [_][]const u8{
"input/output",
"foo/bar",
};
for (no_match_cases) |input| {
var result = re.search(input, .{});
if (result) |*reg| {
reg.deinit();
return error.TestUnexpectedResult;
} else |_| {}
}
}