summaryrefslogtreecommitdiff
path: root/src/look.zig
blob: f34fa36707071807cbd55cff50a4b0f3383f0857 (plain) (blame)
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
//! What a click on text MEANS. The acme "look" (right click / Enter): expand
//! the click to a file-ish word, then resolve it against the pane's directory.
//! This is the one deliberately platform-divergent file — the divergence is a
//! comptime switch on pardes.platform, used the way the stdlib switches on
//! os.tag, so every platform's behavior sits in the same screenful:
//!   tty/gui  — the word resolves through the real filesystem (realpath,
//!              open(O_DIRECTORY)); dirs open shells, files open file panes.
//!   web      — tracked Pardes .zig sources form a build-generated read-only
//!              filesystem; URLs still open in a new tab.
const std = @import("std");
const builtin = @import("builtin");
const libc = std.c;
const pardes = @import("pardes.zig");
const embedded_sources = if (pardes.platform == .web) @import("embedded_sources") else struct {
    pub const Source = struct { path: []const u8, contents: []const u8 };
    pub const all = [_]Source{};
};

extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8;

/// file-ish word chars (acme isfilec): alnum + . - + / : @ _ ~
pub fn isFileChar(c: u8) bool {
    return std.ascii.isAlphanumeric(c) or switch (c) {
        '.', '-', '+', '/', ':', '@', '_', '~' => true,
        else => false,
    };
}

/// peel a trailing :LINE[:COL] suffix (both 1-based, 0 = absent):
///   main.zig:100    -> {main.zig, 100, 0}
///   main.zig:100:7  -> {main.zig, 100, 7}
///   main.zig:100:   -> {main.zig, 100, 0}    grep -n's trailing delimiter
pub fn parsePathLine(tok: []const u8) struct { path: []const u8, line: usize, col: usize } {
    var sep: usize = 0;
    while (sep < tok.len) : (sep += 1) {
        if (tok[sep] != ':') continue;
        var j = sep + 1;
        var line: usize = 0;
        while (j < tok.len and std.ascii.isDigit(tok[j])) : (j += 1) line = line * 10 + (tok[j] - '0');
        if (j == sep + 1) continue; // no digits after ':'
        if (j < tok.len and tok[j] != ':') continue; // junk after the number
        var col: usize = 0;
        if (j < tok.len) {
            var k = j + 1;
            while (k < tok.len and std.ascii.isDigit(tok[k])) : (k += 1) col = col * 10 + (tok[k] - '0');
            // digits, and nothing but a delimiter after them, or no column
            if (k == j + 1 or (k < tok.len and tok[k] != ':')) col = 0;
        }
        return .{ .path = tok[0..sep], .line = line, .col = col };
    }
    return .{ .path = tok, .line = 0, .col = 0 };
}

pub const Target = union(enum) {
    none,
    dir: []const u8, // resolved absolute path, in caller's buf
    file: struct { path: []const u8, line: usize, col: usize },
    image: struct { path: []const u8 },
    url: []const u8,
    /// `@p7:10:5` — pane 7, line 10, column 5 (0 = unspecified). The one
    /// target that names a live pane instead of a path, because terminals and
    /// output buffers have no file for a location to point at.
    pane: struct { id: usize, line: usize, col: usize },
};

const image_exts = [_][]const u8{ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ppm", ".pgm", ".tga" };

pub fn isImagePath(path: []const u8) bool {
    for (image_exts) |ext| {
        if (std.ascii.endsWithIgnoreCase(path, ext)) return true;
    }
    return false;
}

/// Resolve a looked-at word against the pane's directory. `realbuf` must
/// outlive the returned Target (native paths point into it; web paths are
/// process-lifetime slices in the embedded source archive).
pub fn resolve(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;

    // `@pN` addresses a pane, not a path: every platform, before the fs.
    if (word.len > 2 and word[0] == '@' and word[1] == 'p') {
        var id: usize = 0;
        for (word[2..]) |c| {
            if (!std.ascii.isDigit(c)) break;
            id = id * 10 + (c - '0');
        } else return .{ .pane = .{ .id = id, .line = pl.line, .col = pl.col } };
    }

    if (platform_has_fs) {
        var joinbuf: [2048]u8 = undefined;
        const joined: ?[:0]u8 = if (word[0] == '/')
            (std.fmt.bufPrintSentinel(&joinbuf, "{s}", .{word}, 0) catch null)
        else
            (std.fmt.bufPrintSentinel(&joinbuf, "{s}/{s}", .{ cwd, word }, 0) catch null);
        const jz = joined orelse return .none;
        const rp = realpath(jz.ptr, realbuf) orelse return .none;
        const resolved = std.mem.span(rp);
        if (isDir(rp)) return .{ .dir = resolved };
        if (isImagePath(resolved)) return .{ .image = .{ .path = resolved } };
        return .{ .file = .{ .path = resolved, .line = pl.line, .col = pl.col } };
    } else {
        // web: URLs leave the app; tracked Zig sources resolve inside the
        // build-generated, read-only source filesystem.
        if (std.mem.startsWith(u8, trimmed, "http://") or std.mem.startsWith(u8, trimmed, "https://"))
            return .{ .url = trimmed };
        if (resolveEmbedded(word, cwd, realbuf)) |source|
            return .{ .file = .{ .path = source.path, .line = pl.line, .col = pl.col } };
        return .none;
    }
}

/// Resolve a source path without teaching the core about a browser filesystem.
/// Cwd-relative and absolute dump paths are normalized, with printed archive
/// paths also accepted root-relative. The suffix match lets a dump made in
/// `/host/repo` address names that deliberately remain relative to the root.
fn resolveEmbedded(word: []const u8, cwd: []const u8, scratch: *[4096]u8) ?embedded_sources.Source {
    var wordbuf: [4096]u8 = undefined;
    const normalized_word = normalizeVirtualPath(word, &wordbuf) orelse return null;
    if (word.len > 0 and word[0] == '/') return findEmbeddedSource(normalized_word, true);

    var joined: [4096]u8 = undefined;
    if (std.fmt.bufPrint(&joined, "{s}/{s}", .{ cwd, word }) catch null) |candidate|
        if (normalizeVirtualPath(candidate, scratch)) |normalized|
            if (findEmbeddedSource(normalized, true)) |source| return source;
    // A printed archive path is root-relative even when its surrounding dump
    // pane came from some unrelated cwd.
    return findEmbeddedSource(normalized_word, false);
}

fn normalizeVirtualPath(path: []const u8, out: *[4096]u8) ?[]const u8 {
    var len: usize = 0;
    var parts = std.mem.tokenizeAny(u8, path, "/\\");
    while (parts.next()) |part| {
        if (std.mem.eql(u8, part, ".")) continue;
        if (std.mem.eql(u8, part, "..")) {
            while (len > 0 and out[len - 1] != '/') len -= 1;
            if (len > 0) len -= 1;
            continue;
        }
        const extra = part.len + @intFromBool(len != 0);
        if (len + extra > out.len) return null;
        if (len != 0) {
            out[len] = '/';
            len += 1;
        }
        @memcpy(out[len..][0..part.len], part);
        len += part.len;
    }
    if (len == 0) return null;
    return out[0..len];
}

fn findEmbeddedSource(path: []const u8, allow_root_suffix: bool) ?embedded_sources.Source {
    for (embedded_sources.all) |source|
        if (std.mem.eql(u8, source.path, path)) return source;
    if (!allow_root_suffix) return null;
    for (embedded_sources.all) |source| {
        if (path.len <= source.path.len or path[path.len - source.path.len - 1] != '/') continue;
        if (std.mem.endsWith(u8, path, source.path)) return source;
    }
    return null;
}

const platform_has_fs = switch (pardes.platform) {
    .tty, .gui => true,
    .web => false,
};

/// true if `path` exists and is a directory (open(O_DIRECTORY), no stat needed)
fn isDir(path: [*:0]const u8) bool {
    const fd = libc.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true });
    if (fd < 0) return false;
    _ = libc.close(fd);
    return true;
}

/// Read a whole file (gpa-owned) — the look side of opening a file pane. Web
/// reads from the generated source archive; native shells read the real fs.
pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
    if (!platform_has_fs) {
        var normalized_buf: [4096]u8 = undefined;
        const normalized = normalizeVirtualPath(path, &normalized_buf) orelse return error.OpenFailed;
        const source = findEmbeddedSource(normalized, true) orelse return error.OpenFailed;
        return gpa.dupe(u8, source.contents);
    }
    var pathbuf: [4096]u8 = undefined;
    const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong;
    const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
    if (fd < 0) return error.OpenFailed;
    defer _ = libc.close(fd);
    var buf: std.ArrayList(u8) = .empty;
    errdefer buf.deinit(gpa);
    var chunk: [16384]u8 = undefined;
    while (true) {
        const n = libc.read(fd, &chunk, chunk.len);
        if (n < 0) {
            if (libc.errno(n) == .INTR) continue;
            return error.ReadFailed;
        }
        if (n == 0) break;
        try buf.appendSlice(gpa, chunk[0..@intCast(n)]);
    }
    return buf.toOwnedSlice(gpa);
}

// ---- shell cwd: what directory a pane's looks resolve against ----

// macOS has no /proc; libproc's proc_pidinfo(PROC_PIDVNODEPATHINFO) yields the
// cwd vnode path. Not in std.c — layout from xnu's sys/proc_info.h.
const vnode_info_path = extern struct {
    vi: [152]u8 align(8), // struct vnode_info: vinfo_stat + type + pad + fsid
    path: [1024]u8, // MAXPATHLEN
};
const proc_vnodepathinfo = extern struct {
    cdir: vnode_info_path,
    rdir: vnode_info_path,
};
const PROC_PIDVNODEPATHINFO: c_int = 9;
extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int;

/// Live cwd of a shell process (pane tags, look resolution). linux reads
/// /proc/<pid>/cwd, darwin asks libproc; other POSIX systems have no cheap
/// answer — return null and panes keep their spawn-time cwd (callers already
/// tolerate failure: dead shells have no cwd either).
pub fn shellCwd(pid: libc.pid_t, buf: *[1024]u8) ?[]const u8 {
    switch (builtin.os.tag) {
        .linux => {
            var pbuf: [64]u8 = undefined;
            const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pid}, 0) catch return null;
            const n = libc.readlink(path, buf, buf.len);
            if (n <= 0) return null;
            return buf[0..@intCast(n)];
        },
        .macos, .ios, .tvos, .watchos, .visionos => {
            var info: proc_vnodepathinfo = undefined;
            const n = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &info, @sizeOf(proc_vnodepathinfo));
            if (n < @as(c_int, @sizeOf(proc_vnodepathinfo))) return null;
            const path = std.mem.sliceTo(&info.cdir.path, 0);
            if (path.len == 0) return null;
            @memcpy(buf[0..path.len], path);
            return buf[0..path.len];
        },
        else => return null,
    }
}