Add ability use script as cwd.
This commit is contained in:
+223
-3
@@ -7,6 +7,11 @@
|
||||
//! through `{{name}}` substitution first. That is what makes one layout usable
|
||||
//! against any number of projects.
|
||||
//!
|
||||
//! A `cwd` goes one step further: `$(...)` in it is run as a shell command and
|
||||
//! replaced by what it prints. A parameter can say *which* project; only a
|
||||
//! command can answer "wherever that branch is checked out". See `expandPath`,
|
||||
//! and `script.zig` for what running one costs.
|
||||
//!
|
||||
//! Everything lives in a single JSON file under the user's config directory.
|
||||
//! JSON because the app writes this file as well as reading it: layouts are
|
||||
//! authored in the app, and a format we can round-trip without a hand-written
|
||||
@@ -26,6 +31,8 @@
|
||||
const std = @import("std");
|
||||
const glib = @import("glib");
|
||||
|
||||
const script = @import("script.zig");
|
||||
|
||||
const Layouts = @This();
|
||||
|
||||
/// Bumped only if the on-disk shape changes incompatibly. Read but not yet
|
||||
@@ -553,15 +560,28 @@ fn lookup(bindings: []const Binding, name: []const u8) ?[]const u8 {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Expand a path the way a shell would treat it: `{{...}}` first, then a
|
||||
/// leading `~`. Returns an empty slice for an empty template, meaning
|
||||
/// Expand a path the way a shell would treat it: `{{...}}`, then `$(...)`,
|
||||
/// then a leading `~`. Returns an empty slice for an empty template, meaning
|
||||
/// "wherever the app was started".
|
||||
///
|
||||
/// Parameters are substituted before the scripts run, which is what lets a
|
||||
/// script be aimed by one: `$(git -C ~/src/{{repo}} rev-parse --show-toplevel)`.
|
||||
/// Every parameter is also in the script's environment as `PLAYPEN_<NAME>`,
|
||||
/// which is the form to reach for when a value might contain a space.
|
||||
///
|
||||
/// `~` is expanded last so that a script is free to print one — a helper that
|
||||
/// answers `~/projects/foo` lands in the same place as a field that says so.
|
||||
pub fn expandPath(
|
||||
alloc: std.mem.Allocator,
|
||||
template: []const u8,
|
||||
bindings: []const Binding,
|
||||
) ![]u8 {
|
||||
const expanded = try expand(alloc, template, bindings);
|
||||
const substituted = try expand(alloc, template, bindings);
|
||||
const expanded = blk: {
|
||||
defer alloc.free(substituted);
|
||||
break :blk try expandScripts(alloc, substituted, bindings);
|
||||
};
|
||||
|
||||
if (!std.mem.startsWith(u8, expanded, "~")) return expanded;
|
||||
if (expanded.len > 1 and expanded[1] != '/') return expanded;
|
||||
|
||||
@@ -571,3 +591,203 @@ pub fn expandPath(
|
||||
|
||||
return std.fmt.allocPrint(alloc, "{s}{s}", .{ home, expanded[1..] });
|
||||
}
|
||||
|
||||
/// Replace every `$(command)` with what the command prints.
|
||||
///
|
||||
/// Applied to the directory only. A pane's *script* field is already typed
|
||||
/// into a shell, which does its own command substitution — running it here
|
||||
/// first would evaluate it a second time, at a different moment, with
|
||||
/// different results.
|
||||
///
|
||||
/// A failing command leaves an empty string behind rather than aborting the
|
||||
/// tab. The rest of the layout is usually fine, and a pane that opened in the
|
||||
/// wrong directory is both obvious and recoverable; a tab that refused to open
|
||||
/// because one of six panes couldn't resolve its path is neither.
|
||||
fn expandScripts(
|
||||
alloc: std.mem.Allocator,
|
||||
template: []const u8,
|
||||
bindings: []const Binding,
|
||||
) ![]u8 {
|
||||
// The overwhelmingly common case is a plain path, and it should not pay
|
||||
// for the parameter marshalling below.
|
||||
if (std.mem.indexOf(u8, template, "$(") == null) return alloc.dupe(u8, template);
|
||||
|
||||
const vars = try alloc.alloc(script.Var, bindings.len);
|
||||
defer alloc.free(vars);
|
||||
for (bindings, 0..) |b, i| vars[i] = .{ .name = b.name, .value = b.value };
|
||||
|
||||
var out: std.ArrayListUnmanaged(u8) = .empty;
|
||||
errdefer out.deinit(alloc);
|
||||
|
||||
var rest = template;
|
||||
while (std.mem.indexOf(u8, rest, "$(")) |open| {
|
||||
const body = rest[open + 2 ..];
|
||||
|
||||
// An unclosed `$(` is left exactly as typed, for the same reason a
|
||||
// `{{name}}` with no such parameter is: it is a typo, and showing it
|
||||
// is how the typo gets found.
|
||||
const close = closingParen(body) orelse break;
|
||||
|
||||
try out.appendSlice(alloc, rest[0..open]);
|
||||
|
||||
const command = body[0..close];
|
||||
if (script.run(alloc, command, vars)) |result| {
|
||||
defer alloc.free(result);
|
||||
try out.appendSlice(alloc, result);
|
||||
} else |err| {
|
||||
std.log.warn("layout directory script failed ({s}): {s}", .{
|
||||
@errorName(err),
|
||||
command,
|
||||
});
|
||||
}
|
||||
|
||||
rest = body[close + 1 ..];
|
||||
}
|
||||
try out.appendSlice(alloc, rest);
|
||||
|
||||
return out.toOwnedSlice(alloc);
|
||||
}
|
||||
|
||||
/// Offset of the `)` that closes an already-consumed `$(`.
|
||||
///
|
||||
/// Nesting is counted so that `$(dirname $(which zig))` hands the whole thing
|
||||
/// to the shell in one piece, which then evaluates the inner one itself.
|
||||
fn closingParen(s: []const u8) ?usize {
|
||||
var depth: usize = 0;
|
||||
for (s, 0..) |ch, i| {
|
||||
switch (ch) {
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
if (depth == 0) return i;
|
||||
depth -= 1;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tests
|
||||
//
|
||||
// These cover the substitution pipeline, which is the part of a layout that
|
||||
// runs long after it was written and with values it has never seen. The
|
||||
// `$(...)` cases really do spawn a shell — the point of the feature is what
|
||||
// comes back from one, and a fake would only prove the parser.
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
/// Run `expandPath` and hand back the result for comparison.
|
||||
fn expectPath(
|
||||
expected: []const u8,
|
||||
template: []const u8,
|
||||
bindings: []const Binding,
|
||||
) !void {
|
||||
const got = try expandPath(testing.allocator, template, bindings);
|
||||
defer testing.allocator.free(got);
|
||||
try testing.expectEqualStrings(expected, got);
|
||||
}
|
||||
|
||||
test "expandPath leaves a plain path alone" {
|
||||
try expectPath("/tmp/x", "/tmp/x", &.{});
|
||||
}
|
||||
|
||||
test "expandPath substitutes parameters" {
|
||||
try expectPath("/tmp/foo", "/tmp/{{name}}", &.{.{ .name = "name", .value = "foo" }});
|
||||
}
|
||||
|
||||
test "expandPath runs a script" {
|
||||
try expectPath("hello", "$(echo hello)", &.{});
|
||||
}
|
||||
|
||||
test "expandPath trims the trailing newline a command leaves" {
|
||||
try expectPath("/tmp", "$(cd /tmp && pwd)", &.{});
|
||||
}
|
||||
|
||||
test "expandPath splices a script into the middle of a path" {
|
||||
try expectPath("/a/b/c", "/a/$(echo b)/c", &.{});
|
||||
}
|
||||
|
||||
test "expandPath runs several scripts in one field" {
|
||||
try expectPath("/a/b", "$(echo /a)/$(echo b)", &.{});
|
||||
}
|
||||
|
||||
// The two ways a script gets at a parameter. Substitution is the readable one;
|
||||
// the environment variable is the one that survives a value with a space in it.
|
||||
test "a script sees parameters substituted into it" {
|
||||
try expectPath(
|
||||
"/tmp/foo",
|
||||
"$(echo /tmp/{{name}})",
|
||||
&.{.{ .name = "name", .value = "foo" }},
|
||||
);
|
||||
}
|
||||
|
||||
test "a script sees parameters in its environment" {
|
||||
try expectPath(
|
||||
"/tmp/foo",
|
||||
"$(echo /tmp/$PLAYPEN_NAME)",
|
||||
&.{.{ .name = "name", .value = "foo" }},
|
||||
);
|
||||
}
|
||||
|
||||
test "an environment parameter survives a value containing spaces" {
|
||||
try expectPath(
|
||||
"a b c",
|
||||
"$(printf %s \"$PLAYPEN_WHAT\")",
|
||||
&.{.{ .name = "what", .value = "a b c" }},
|
||||
);
|
||||
}
|
||||
|
||||
test "a parameter name that isn't a shell identifier is still reachable" {
|
||||
try expectPath(
|
||||
"ok",
|
||||
"$(printf %s \"$PLAYPEN_REPO_PATH\")",
|
||||
&.{.{ .name = "repo-path", .value = "ok" }},
|
||||
);
|
||||
}
|
||||
|
||||
test "~ is expanded after the script, so a script may print one" {
|
||||
const home = std.mem.span(glib.getHomeDir());
|
||||
if (home.len == 0) return error.SkipZigTest;
|
||||
|
||||
const expected = try std.fmt.allocPrint(testing.allocator, "{s}/x", .{home});
|
||||
defer testing.allocator.free(expected);
|
||||
|
||||
try expectPath(expected, "$(printf '~/x')", &.{});
|
||||
}
|
||||
|
||||
test "nested substitution is handed to the shell whole" {
|
||||
try expectPath("b", "$(echo $(echo b))", &.{});
|
||||
}
|
||||
|
||||
// A script that fails leaves an empty string rather than taking the tab with
|
||||
// it, so the surrounding path is still assembled.
|
||||
test "a failing script substitutes nothing" {
|
||||
try expectPath("/a//c", "/a/$(exit 3)/c", &.{});
|
||||
}
|
||||
|
||||
test "a script that prints nothing substitutes nothing" {
|
||||
try expectPath("/a//c", "/a/$(true)/c", &.{});
|
||||
}
|
||||
|
||||
// An unclosed `$(` is a typo, and the way a typo gets found is by staying
|
||||
// visible rather than being silently eaten.
|
||||
test "an unclosed script marker is left as written" {
|
||||
try expectPath("/a/$(echo b", "/a/$(echo b", &.{});
|
||||
}
|
||||
|
||||
test "the script marker is inert in an ordinary path" {
|
||||
try expectPath("/a/$b/c", "/a/$b/c", &.{});
|
||||
}
|
||||
|
||||
test "closingParen counts nesting" {
|
||||
try testing.expectEqual(@as(?usize, 3), closingParen("abc)"));
|
||||
try testing.expectEqual(@as(?usize, 8), closingParen("a $(b) c)"));
|
||||
try testing.expectEqual(@as(?usize, null), closingParen("a $(b c"));
|
||||
}
|
||||
|
||||
// `expand` is older than the scripts, but it is the first stage of the same
|
||||
// pipeline and its edge cases decide what the shell ends up seeing.
|
||||
test "an unknown parameter is left visible rather than blanked" {
|
||||
try expectPath("/tmp/{{nope}}", "/tmp/{{nope}}", &.{});
|
||||
}
|
||||
|
||||
@@ -153,6 +153,10 @@ pub fn present(
|
||||
|
||||
// ---- panes -----------------------------------------------------------
|
||||
content.append(heading("Panes"));
|
||||
content.append(hint(
|
||||
"A directory can be $(a command), and what it prints becomes the path. " ++
|
||||
"Parameters reach it as {{name}} or as $PLAYPEN_NAME.",
|
||||
));
|
||||
|
||||
// Leaves in tree order, which reads top-left to bottom-right on screen.
|
||||
var leaves: std.ArrayListUnmanaged(*Layouts.Node) = .empty;
|
||||
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
//! Running a command and taking what it prints, for the `$(...)` form in a
|
||||
//! layout's directory field.
|
||||
//!
|
||||
//! A layout's directory is usually a path, and a parameter is usually enough to
|
||||
//! aim it — `~/projects/{{name}}`. What a parameter cannot do is *look
|
||||
//! something up*: the worktree a branch is checked out in, the newest build
|
||||
//! directory, the project root above wherever you happen to be. Those are one
|
||||
//! line of shell each, and this is what lets that line go in the field.
|
||||
//!
|
||||
//! The command runs under `/bin/sh -c`, so pipes, `&&` and the rest work as
|
||||
//! written. Its stdout is the value; its stderr is left attached to Playpen's
|
||||
//! own, so a script that complains is complaining somewhere you can read it.
|
||||
//!
|
||||
//! **This blocks the main loop while it runs.** Layouts are built by a
|
||||
//! synchronous walk of the split tree — panes are created, wired up and
|
||||
//! appended in one pass — and unpicking that into a callback chain to await a
|
||||
//! subprocess would be a large change for a call that is normally a few
|
||||
//! milliseconds. The timeout below is what keeps that honest: a script that
|
||||
//! hangs costs a visible pause and then an error, rather than a window that
|
||||
//! never comes back.
|
||||
//!
|
||||
//! Like `Pty.zig`, the libc calls are declared through `std.c` rather than
|
||||
//! `std.posix`, which has been churning across Zig releases.
|
||||
|
||||
const std = @import("std");
|
||||
const glib = @import("glib");
|
||||
|
||||
const c = std.c;
|
||||
|
||||
/// How long a command may run before it is killed.
|
||||
///
|
||||
/// Deliberately short. This is a value being spliced into a directory field
|
||||
/// while a tab is opening, so anything that isn't nearly instant is already
|
||||
/// the wrong shape for the job — five seconds is long enough for a `git` call
|
||||
/// on a cold cache and short enough that a mistake is an annoyance rather than
|
||||
/// a hang.
|
||||
const timeout_ms: i64 = 5000;
|
||||
|
||||
/// Cap on captured output. A directory path is a couple of hundred bytes; this
|
||||
/// is only here so that pointing the field at something that streams forever
|
||||
/// stops rather than eats memory until the timeout.
|
||||
const max_output = 64 * 1024;
|
||||
|
||||
/// Prefix for the parameter environment variables. Namespaced because these
|
||||
/// land in the environment of a script the user wrote, alongside their own.
|
||||
const env_prefix = "PLAYPEN_";
|
||||
|
||||
/// One of the layout's parameters, as the script will see it.
|
||||
pub const Var = struct {
|
||||
name: []const u8,
|
||||
value: []const u8,
|
||||
};
|
||||
|
||||
pub const Error = error{
|
||||
OutOfMemory,
|
||||
SpawnFailed,
|
||||
Timeout,
|
||||
|
||||
/// The command ran and exited non-zero, or died on a signal.
|
||||
Failed,
|
||||
};
|
||||
|
||||
/// Run `command`, returning its trimmed stdout. Caller owns the result.
|
||||
pub fn run(alloc: std.mem.Allocator, command: []const u8, vars: []const Var) Error![]u8 {
|
||||
var argv0 = "/bin/sh".*;
|
||||
var argv1 = "-c".*;
|
||||
|
||||
const command_z = try alloc.dupeZ(u8, command);
|
||||
defer alloc.free(command_z);
|
||||
|
||||
var argv = [_:null]?[*:0]u8{ &argv0, &argv1, command_z.ptr };
|
||||
|
||||
const envp = try environment(alloc, vars);
|
||||
defer glib.strfreev(envp);
|
||||
|
||||
var pid: glib.Pid = undefined;
|
||||
var out_fd: c_int = -1;
|
||||
var spawn_err: ?*glib.Error = null;
|
||||
|
||||
// stdin and stderr are passed as null, which means "inherit". Inheriting
|
||||
// stderr is the point: it is where a script's own diagnostics go, and
|
||||
// swallowing them would make a broken one-liner in a text field about as
|
||||
// hard to debug as anything in this app gets.
|
||||
if (glib.spawnAsyncWithPipes(
|
||||
null,
|
||||
&argv,
|
||||
envp,
|
||||
.{ .do_not_reap_child = true },
|
||||
null,
|
||||
null,
|
||||
&pid,
|
||||
null,
|
||||
&out_fd,
|
||||
null,
|
||||
&spawn_err,
|
||||
) == 0) {
|
||||
if (spawn_err) |e| {
|
||||
std.log.warn("could not run script: {s}", .{e.f_message orelse "unknown"});
|
||||
e.free();
|
||||
}
|
||||
return error.SpawnFailed;
|
||||
}
|
||||
|
||||
var out: std.ArrayListUnmanaged(u8) = .empty;
|
||||
defer out.deinit(alloc);
|
||||
|
||||
const timed_out = collect(alloc, out_fd, &out) catch |err| switch (err) {
|
||||
error.OutOfMemory => blk: {
|
||||
// Still have to reap the child before giving up, so the failure is
|
||||
// recorded and handled after the wait below rather than returned
|
||||
// from here.
|
||||
break :blk true;
|
||||
},
|
||||
};
|
||||
|
||||
_ = c.close(out_fd);
|
||||
|
||||
// A timed-out child is killed rather than left behind: it is attached to
|
||||
// our stderr and would keep writing there long after the tab it was
|
||||
// opening had given up on it.
|
||||
if (timed_out) _ = c.kill(pid, c.SIG.KILL);
|
||||
|
||||
var status: c_int = 0;
|
||||
_ = c.waitpid(pid, &status, 0);
|
||||
glib.spawnClosePid(pid);
|
||||
|
||||
if (timed_out) return error.Timeout;
|
||||
|
||||
// Covers both a non-zero exit and death by signal. The distinction doesn't
|
||||
// change what we do about it, and the script's own stderr has already said
|
||||
// more than an exit code could.
|
||||
if (status != 0) return error.Failed;
|
||||
|
||||
// Trailing newline trimmed because every well-behaved command emits one and
|
||||
// no directory wants it. Leading whitespace goes too — it is never
|
||||
// meaningful in a path and is easy to leave in a heredoc.
|
||||
const text = std.mem.trim(u8, out.items, &std.ascii.whitespace);
|
||||
return alloc.dupe(u8, text);
|
||||
}
|
||||
|
||||
/// Read `fd` to EOF or until the deadline. Returns whether it timed out.
|
||||
fn collect(
|
||||
alloc: std.mem.Allocator,
|
||||
fd: c_int,
|
||||
out: *std.ArrayListUnmanaged(u8),
|
||||
) error{OutOfMemory}!bool {
|
||||
// GLib's monotonic clock rather than a wall clock: this is a duration, and
|
||||
// a clock step mid-lookup should not decide whether the script is killed.
|
||||
const start = glib.getMonotonicTime();
|
||||
|
||||
while (true) {
|
||||
const elapsed_ms = @divTrunc(glib.getMonotonicTime() - start, std.time.us_per_ms);
|
||||
const remaining = timeout_ms - elapsed_ms;
|
||||
if (remaining <= 0) return true;
|
||||
|
||||
var fds = [_]c.pollfd{.{ .fd = fd, .events = c.POLL.IN, .revents = 0 }};
|
||||
const ready = c.poll(&fds, 1, @intCast(remaining));
|
||||
|
||||
// Negative is an error — including EINTR, which we treat as "stop
|
||||
// reading" rather than retrying. A signal arriving mid-lookup is not
|
||||
// worth a retry loop, and whatever was read so far is still returned.
|
||||
if (ready < 0) return false;
|
||||
if (ready == 0) return true;
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
const got = c.read(fd, &buf, buf.len);
|
||||
if (got <= 0) return false; // EOF, or an error we treat as one.
|
||||
|
||||
const room = max_output - out.items.len;
|
||||
if (room == 0) return false;
|
||||
try out.appendSlice(alloc, buf[0..@min(@as(usize, @intCast(got)), room)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Playpen's own environment plus one variable per layout parameter.
|
||||
///
|
||||
/// These exist alongside `{{name}}` substitution rather than instead of it,
|
||||
/// and they are the safer of the two whenever a value might contain a space:
|
||||
/// `{{name}}` splices text straight into the command line, where a path with a
|
||||
/// space in it silently becomes two arguments, while `"$PLAYPEN_NAME"` cannot.
|
||||
fn environment(alloc: std.mem.Allocator, vars: []const Var) Error![*:null]?[*:0]u8 {
|
||||
var envp = glib.getEnviron();
|
||||
|
||||
for (vars) |v| {
|
||||
const name = envName(alloc, v.name) catch continue;
|
||||
defer alloc.free(name);
|
||||
|
||||
const value = alloc.dupeZ(u8, v.value) catch continue;
|
||||
defer alloc.free(value);
|
||||
|
||||
// Takes ownership of `envp` and hands back the updated list.
|
||||
envp = glib.environSetenv(envp, name.ptr, value.ptr, 1);
|
||||
}
|
||||
|
||||
return envp;
|
||||
}
|
||||
|
||||
/// `branch` becomes `PLAYPEN_BRANCH`. Anything that can't appear in a shell
|
||||
/// variable name becomes an underscore, so a parameter named `repo-path` is
|
||||
/// still reachable rather than quietly absent.
|
||||
fn envName(alloc: std.mem.Allocator, name: []const u8) ![:0]u8 {
|
||||
const out = try alloc.allocSentinel(u8, env_prefix.len + name.len, 0);
|
||||
@memcpy(out[0..env_prefix.len], env_prefix);
|
||||
|
||||
for (name, env_prefix.len..) |ch, i| {
|
||||
out[i] = switch (ch) {
|
||||
'a'...'z' => std.ascii.toUpper(ch),
|
||||
'A'...'Z', '0'...'9' => ch,
|
||||
else => '_',
|
||||
};
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user