diff --git a/src/Surface.zig b/src/Surface.zig index c56c9791c02..33d8697ca3e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4396,7 +4396,28 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { const resolved_path = try self.resolvePathForOpening(str); defer if (resolved_path) |p| self.alloc.free(p); - const url_to_open = resolved_path orelse str; + // Compilers and tools commonly suffix file paths with + // `:line[:col]` (e.g. `src/main.zig:42:10`). If the text + // as-is doesn't resolve to a real file but the path with + // the suffix stripped does, open the stripped path so that + // the click does something useful instead of silently + // failing. Exact matches above take precedence so that + // files whose names actually contain a numeric suffix + // still open correctly. + const stripped_path: ?[]const u8 = stripped: { + if (resolved_path != null) break :stripped null; + const parsed = input.Link.parseFilePath(str); + if (parsed.line == null) break :stripped null; + if (std.fs.path.isAbsolute(parsed.path)) { + std.fs.accessAbsolute(parsed.path, .{}) catch + break :stripped null; + break :stripped try self.alloc.dupe(u8, parsed.path); + } + break :stripped try self.resolvePathForOpening(parsed.path); + }; + defer if (stripped_path) |p| self.alloc.free(p); + + const url_to_open = resolved_path orelse stripped_path orelse str; try self.openUrl(.{ .kind = .unknown, .url = url_to_open }); }, diff --git a/src/input/Link.zig b/src/input/Link.zig index 37b45dbd1e8..f6a074d94d8 100644 --- a/src/input/Link.zig +++ b/src/input/Link.zig @@ -51,6 +51,73 @@ pub const Highlight = union(enum) { hover_mods: Mods, }; +/// The result of parsing a matched link string for a trailing +/// `:line[:col]` suffix, e.g. `src/main.zig:42:10`. +pub const ParsedFilePath = struct { + path: []const u8, + line: ?[]const u8 = null, + col: ?[]const u8 = null, +}; + +/// Parse a trailing `:line[:col]` suffix from a matched link string. +/// The numeric components are returned as substrings of the input; +/// they are validated to be all digits but not range-checked. +pub fn parseFilePath(str: []const u8) ParsedFilePath { + var result: ParsedFilePath = .{ .path = str }; + for (0..2) |_| { + const idx = std.mem.lastIndexOfScalar(u8, result.path, ':') orelse break; + const seg = result.path[idx + 1 ..]; + if (!allDigits(seg)) break; + result.col = result.line; + result.line = seg; + result.path = result.path[0..idx]; + } + + return result; +} + +fn allDigits(s: []const u8) bool { + if (s.len == 0) return false; + for (s) |c| if (!std.ascii.isDigit(c)) return false; + return true; +} + +test "parseFilePath" { + const testing = std.testing; + + { + const p = parseFilePath("src/main.zig"); + try testing.expectEqualStrings("src/main.zig", p.path); + try testing.expect(p.line == null); + try testing.expect(p.col == null); + } + { + const p = parseFilePath("src/main.zig:42"); + try testing.expectEqualStrings("src/main.zig", p.path); + try testing.expectEqualStrings("42", p.line.?); + try testing.expect(p.col == null); + } + { + const p = parseFilePath("/abs/path/main.zig:42:10"); + try testing.expectEqualStrings("/abs/path/main.zig", p.path); + try testing.expectEqualStrings("42", p.line.?); + try testing.expectEqualStrings("10", p.col.?); + } + { + // Numeric suffix segments are parsed even for non-path strings; + // callers are expected to validate the path on disk. + const p = parseFilePath("magnet:?xt=urn:btih:1234567890"); + try testing.expectEqualStrings("magnet:?xt=urn:btih", p.path); + try testing.expectEqualStrings("1234567890", p.line.?); + try testing.expect(p.col == null); + } + { + const p = parseFilePath("foo:bar"); + try testing.expectEqualStrings("foo:bar", p.path); + try testing.expect(p.line == null); + } +} + /// Returns a new oni.Regex that can be used to match the link. pub fn oniRegex(self: *const Link) !oni.Regex { return try oni.Regex.init(