const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; const pardes = @import("pardes.zig"); const config = @import("config.zig"); const pdf_enabled = @import("pardes_config").mupdf; const fs = @import("fs.zig"); const platform_has_fs = fs.platform_has_fs; extern "c" fn fork() c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; const opener_path: ?[*:0]const u8 = switch (builtin.os.tag) { .linux => "/usr/bin/xdg-open", .macos => "/usr/bin/open", else => null, }; pub fn openLink(url: []const u8) void { const opener = opener_path orelse return; var buf: [1024]u8 = undefined; const url_z = std.fmt.bufPrintSentinel(&buf, "{s}", .{url}, 0) catch return; const pid = fork(); if (pid < 0) return; if (pid == 0) { if (fork() == 0) { const argv: [3:null]?[*:0]const u8 = .{ opener, url_z.ptr, null }; _ = execv(opener, &argv); } _exit(0); } _ = libc.waitpid(pid, null, 0); } pub const Spot = struct { line: usize = 0, col: usize = 0, end_line: usize = 0, end_col: usize = 0, }; fn num(tok: []const u8, i: usize) struct { v: usize, end: usize } { var v: usize = 0; var j = i; while (j < tok.len and std.ascii.isDigit(tok[j])) : (j += 1) v = v *| 10 +| (tok[j] - '0'); return .{ .v = v, .end = j }; } pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end: usize } { var sep: usize = 0; while (sep < tok.len) : (sep += 1) { if (tok[sep] != config.line_col_sep) continue; const l = num(tok, sep + 1); if (l.end == sep + 1) continue; // no digits after ':' const path = tok[0..sep]; var i = l.end; if (i < tok.len and tok[i] == config.range_sep) { const e = num(tok, i + 1); if (e.end == i + 1) continue; // a dash with no number is not a range if (e.end < tok.len and tok[e.end] != config.line_col_sep) continue; // junk after it return .{ .path = path, .at = .{ .line = l.v, .end_line = e.v }, .end = e.end }; } if (i < tok.len and tok[i] != config.line_col_sep) continue; // junk after the number var at: Spot = .{ .line = l.v }; if (i == tok.len) return .{ .path = path, .at = at, .end = i }; const c = num(tok, i + 1); if (c.end == i + 1) return .{ .path = path, .at = at, .end = i }; if (c.end < tok.len and tok[c.end] != config.line_col_sep and tok[c.end] != config.range_sep) return .{ .path = path, .at = at, .end = i }; at.col = c.v; i = c.end; if (i == tok.len or tok[i] != config.range_sep) return .{ .path = path, .at = at, .end = i }; const e = num(tok, i + 1); if (e.end == i + 1) return .{ .path = path, .at = at, .end = i }; at.end_line = at.line; at.end_col = e.v; var end = e.end; if (e.end < tok.len and tok[e.end] == config.line_col_sep) { const e2 = num(tok, e.end + 1); if (e2.end > e.end + 1) { at.end_line = at.end_col; at.end_col = e2.v; end = e2.end; } } return .{ .path = path, .at = at, .end = end }; } return .{ .path = tok, .at = .{}, .end = tok.len }; } test "parsePathLine: spots, ranges, and the paths that merely look like them" { const cases = [_]struct { tok: []const u8, path: []const u8, at: Spot }{ .{ .tok = "main.zig", .path = "main.zig", .at = .{} }, .{ .tok = "main.zig:100", .path = "main.zig", .at = .{ .line = 100 } }, .{ .tok = "main.zig:100:", .path = "main.zig", .at = .{ .line = 100 } }, .{ .tok = "main.zig:100:7", .path = "main.zig", .at = .{ .line = 100, .col = 7 } }, .{ .tok = "main.zig:100-104", .path = "main.zig", .at = .{ .line = 100, .end_line = 104 } }, .{ .tok = "main.zig:100:7-21", .path = "main.zig", .at = .{ .line = 100, .col = 7, .end_line = 100, .end_col = 21 } }, .{ .tok = "main.zig:100:7-104:3", .path = "main.zig", .at = .{ .line = 100, .col = 7, .end_line = 104, .end_col = 3 } }, .{ .tok = "my-file.zig", .path = "my-file.zig", .at = .{} }, .{ .tok = "my-file:10", .path = "my-file", .at = .{ .line = 10 } }, .{ .tok = "x:1-y", .path = "x:1-y", .at = .{} }, .{ .tok = "a-b-c", .path = "a-b-c", .at = .{} }, .{ .tok = "2026-07-30", .path = "2026-07-30", .at = .{} }, .{ .tok = "main.zig:100x", .path = "main.zig:100x", .at = .{} }, .{ .tok = "main.zig:100:7x", .path = "main.zig", .at = .{ .line = 100 } }, }; for (cases) |c| { const got = parsePathLine(c.tok); try std.testing.expectEqualStrings(c.path, got.path); try std.testing.expectEqual(c.at, got.at); } } test "a number too big to be a line saturates instead of taking the editor down" { const huge = "99999999999999999999999999"; const cases = [_][]const u8{ "f.zig:" ++ huge, "f.zig:" ++ huge ++ ":" ++ huge, "f.zig:1-" ++ huge, "f.zig:1:2-" ++ huge ++ ":" ++ huge, }; for (cases) |tok| { const got = parsePathLine(tok); try std.testing.expectEqualStrings("f.zig", got.path); try std.testing.expect(got.at.line >= 1); } var realbuf: [4096]u8 = undefined; const target = resolve(null, "@p" ++ huge, "/tmp", &realbuf); try std.testing.expect(target == .pane); try std.testing.expect(target.pane.id >= 16); } test "parsePathLine: `end` separates a whole-token target from a lenient read" { for ([_][]const u8{ "main.zig", "main.zig:100", "main.zig:100:7", "main.zig:100-104", "main.zig:100:7-21", "main.zig:100:7-104:3", "@p3:10:5", "x:1-y", }) |tok| try std.testing.expectEqual(tok.len, parsePathLine(tok).end); const partial = [_]struct { tok: []const u8, end: usize }{ .{ .tok = "main.zig:100:", .end = "main.zig:100".len }, // trailing ':' is peeled, not parsed .{ .tok = "main.zig:100:7x", .end = "main.zig:100".len }, .{ .tok = "main.zig:100:7 fn main() void {", .end = "main.zig:100".len }, .{ .tok = "main.zig:100:7-21 const x = 1;", .end = "main.zig:100:7-21".len }, .{ .tok = "@p3:10:5 /home/goblin", .end = "@p3:10".len }, }; for (partial) |c| try std.testing.expectEqual(c.end, parsePathLine(c.tok).end); const whole = "main.zig:100-104 whole lines"; try std.testing.expectEqual(whole.len, parsePathLine(whole).end); try std.testing.expectEqualStrings(whole, parsePathLine(whole).path); } pub const FileKind = if (pdf_enabled) enum { text, pdf } else enum { text }; pub const FileTarget = struct { path: []const u8, at: Spot, kind: FileKind = .text, }; pub const Target = union(enum) { none, dir: []const u8, // resolved absolute path, in caller's buf file: FileTarget, image: struct { path: []const u8 }, url: []const u8, pane: struct { id: usize, at: Spot }, }; pub fn isImagePath(path: []const u8) bool { for (config.image_exts) |ext| { if (std.ascii.endsWithIgnoreCase(path, ext)) return true; } return false; } pub fn isPdfPath(path: []const u8) bool { if (comptime !pdf_enabled) return false; return std.ascii.endsWithIgnoreCase(path, ".pdf"); } test "PDF file kinds exist only in MuPDF-enabled builds" { try std.testing.expectEqual(pdf_enabled, isPdfPath("manual.PDF")); try std.testing.expect(!isPdfPath("manual.pdf.txt")); try std.testing.expectEqual( pdf_enabled, std.meta.stringToEnum(FileKind, "pdf") != null, ); try std.testing.expect(std.meta.stringToEnum(std.meta.Tag(Target), "pdf") == null); } test ".pdf Look paths are ordinary files when MuPDF is disabled" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; const target = resolve(null, "docs/design.pdf", ".", &realbuf); switch (target) { .file => |file| { if (comptime pdf_enabled) try std.testing.expectEqual(FileKind.pdf, file.kind) else try std.testing.expectEqual(FileKind.text, file.kind); }, else => return error.PdfDidNotResolveAsFile, } } pub const Span = struct { start: usize, end: usize }; const lead_trim = "([{<\"'`*"; const trail_trim = ")]}>\"'`*,;:.!?"; pub fn wordSpan(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span { if (word.len == 0) return null; var lo: usize = 0; var hi: usize = word.len; while (lo < hi and std.mem.indexOfScalar(u8, lead_trim, word[lo]) != null) lo += 1; while (hi > lo and std.mem.indexOfScalar(u8, trail_trim, word[hi - 1]) != null) hi -= 1; if (lo < hi and resolve(p, word[lo..hi], cwd, realbuf) != .none) return .{ .start = lo, .end = hi }; if (lo == 0 and hi == word.len) return null; if (resolve(p, word, cwd, realbuf) == .none) return null; return .{ .start = 0, .end = word.len }; } test "lookableSpan peels prose punctuation off a path, largest first" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig".len }), wordSpan(null, "src/look.zig", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 1, .end = 1 + "src/look.zig".len }), wordSpan(null, "(src/look.zig),", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12:3".len }), wordSpan(null, "src/look.zig:12:3,", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12".len }), wordSpan(null, "src/look.zig:12:", ".", &realbuf), ); try std.testing.expectEqual(@as(?Span, null), wordSpan(null, "nothing-here", ".", &realbuf)); try std.testing.expectEqual(@as(?Span, null), wordSpan(null, "", ".", &realbuf)); try std.testing.expectEqual(@as(?Span, null), wordSpan(null, "((()))", ".", &realbuf)); } pub fn lineSpan(p: ?*pardes.Pardes, line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span { var lo: usize = 0; while (lo < line.len and (line[lo] == ' ' or line[lo] == '\t')) lo += 1; var hi = std.mem.trimEnd(u8, line, " \t\r").len; while (hi > lo) { var a = lo; var b = hi; while (a < b and std.mem.indexOfScalar(u8, lead_trim, line[a]) != null) a += 1; while (b > a and std.mem.indexOfScalar(u8, trail_trim, line[b - 1]) != null) b -= 1; const cand = line[a..b]; if (cand.len > 0 and parsePathLine(cand).end == cand.len) switch (resolve(p, cand, cwd, realbuf)) { .dir, .file, .image => return .{ .start = a, .end = b }, .url, .pane => if (std.mem.indexOfAny(u8, cand, " \t") == null) return .{ .start = a, .end = b }, .none => {}, }; while (hi > lo and line[hi - 1] != ' ' and line[hi - 1] != '\t') hi -= 1; while (hi > lo and (line[hi - 1] == ' ' or line[hi - 1] == '\t')) hi -= 1; } return null; } test "lookableLineSpan takes the row's location and stops before its text" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12:5-9".len }), lineSpan(null, "src/look.zig:12:5-9 const std = @import(\"std\");", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12:5".len }), lineSpan(null, "src/look.zig:12:5 pub fn resolve", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "@p3:10:5".len }), lineSpan(null, "@p3:10:5 /home/goblin", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 3, .end = 3 + "src/look.zig".len }), lineSpan(null, " (src/look.zig)", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "https://pardes.dev/a".len }), lineSpan(null, "https://pardes.dev/a Chapter One", ".", &realbuf), ); try std.testing.expectEqual( @as(?Span, null), lineSpan(null, "see also src/look.zig", ".", &realbuf), ); try std.testing.expectEqual(@as(?Span, null), lineSpan(null, " ", ".", &realbuf)); try std.testing.expectEqual(@as(?Span, null), lineSpan(null, "", ".", &realbuf)); } test "lookableLineSpan prefers the longest run, so a blank inside a path is one span" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const name = "pardes look span.txt"; try tmp.dir.writeFile(io, .{ .sub_path = name, .data = "" }); try tmp.dir.createDir(io, "subdir", .default_dir); var path: [4096]u8 = undefined; const len = try tmp.dir.realPathFile(io, name, &path); const cwd = std.fs.path.dirname(path[0..len]).?; try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = name.len }), lineSpan(null, name, cwd, &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "subdir".len }), lineSpan(null, "subdir holds pardes look span.txt", cwd, &realbuf), ); } pub fn resolve(p: ?*pardes.Pardes, word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target { const trimmed = std.mem.trim(u8, word_raw, " \t\r\n"); const pl = parsePathLine(trimmed); const word = pl.path; if (word.len == 0) return .none; if (word.len > config.pane_addr.len and std.mem.startsWith(u8, word, config.pane_addr)) { var id: usize = 0; for (word[config.pane_addr.len..]) |c| { if (!std.ascii.isDigit(c)) break; id = id *| 10 +| (c - '0'); } else return .{ .pane = .{ .id = id, .at = pl.at } }; } for (config.url_schemes) |scheme| { if (std.mem.startsWith(u8, trimmed, scheme)) return .{ .url = trimmed }; } const found = fs.resolve(p, word, cwd, realbuf) orelse return .none; if (found.dir) return .{ .dir = found.path }; if (comptime pdf_enabled) if (isPdfPath(found.path)) return .{ .file = .{ .path = found.path, .at = pl.at, .kind = .pdf, } }; if (isImagePath(found.path)) return .{ .image = .{ .path = found.path } }; return .{ .file = .{ .path = found.path, .at = pl.at } }; }