summaryrefslogtreecommitdiff
path: root/src/look.zig
blob: 61970de1035ec8e4336edf77239dac95a8436edb (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
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
//! 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,
};

// Find's safety rails. The core is SYNCHRONOUS — a Find at `/` runs inside the
// keystroke that asked for it — so the walk must end whatever it is pointed at.
// Three caps, because each alone leaks: hits bound the results buffer, depth
// bounds a deep tree, and steps bound a wide shallow one (a pattern that never
// matches would otherwise walk the whole disk without ever filling `hits`).
const find_max_hits = 512;
const find_max_depth = 16;
const find_max_steps = 100_000;

/// `fd`, in-core: every path under `dir` whose NAME contains `pat` (plain
/// case-insensitive substring — fd's default is a regex and pardes has no
/// regex engine to spend on one), one ABSOLUTE path per line into `out`.
/// Absolute because each line is an ordinary look target that outlives the
/// walk: a terminal's cwd can move out from under its own results buffer.
/// Only real directories are entered, so a symlink can never close a cycle,
/// and `.git` is skipped whole (fd's default too).
pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: *std.ArrayList(u8)) void {
    var hits: std.ArrayList([]const u8) = .empty;
    if (platform_has_fs) {
        // Zig 0.16 moved the filesystem behind std.Io; the blocking
        // single-threaded implementation (the one std.debug itself holds) IS
        // the synchronous walk a sans-IO core wants — no pool, no cancelation.
        const io = std.Io.Threaded.global_single_threaded.io();
        var root = std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }) catch return;
        defer root.close(io);
        // walkSelectively, not walk: descending is opt-in, which is the only
        // way to express the depth cap and the .git skip at all.
        var w = root.walkSelectively(arena) catch return;
        defer w.deinit();
        var steps: usize = 0;
        while (steps < find_max_steps and hits.items.len < find_max_hits) {
            steps += 1; // an unreadable dir burns a step too, so it cannot spin
            const e = (w.next(io) catch continue) orelse break;
            if (std.ascii.indexOfIgnoreCase(e.basename, pat) != null)
                hits.append(arena, std.fmt.allocPrint(arena, "{s}/{s}", .{
                    std.mem.trimEnd(u8, dir, "/"), e.path,
                }) catch break) catch break;
            if (e.kind == .directory and e.depth() < find_max_depth and !std.mem.eql(u8, e.basename, ".git"))
                w.enter(io, e) catch {};
        }
    } else {
        // web: the build-generated source archive IS the filesystem, and it is
        // already a flat list of paths — the whole walk is the match.
        for (embedded_sources.all) |s| {
            if (hits.items.len >= find_max_hits) break;
            if (std.ascii.indexOfIgnoreCase(std.fs.path.basename(s.path), pat) != null)
                hits.append(arena, s.path) catch break;
        }
    }
    // readdir order is undefined; sort so the same tree gives the same buffer
    // twice running and n/N walks it in a sane order.
    std.mem.sort([]const u8, hits.items, {}, struct {
        fn lt(_: void, a: []const u8, b: []const u8) bool {
            return std.mem.lessThan(u8, a, b);
        }
    }.lt);
    for (hits.items) |h| {
        out.appendSlice(arena, h) catch return;
        out.append(arena, '\n') catch return;
    }
}

/// 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,
    }
}