summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 18:17:37 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit35cecf26b06cf50d9827056da99d883ca975c39b (patch)
tree7b228b94313880f2d78fa5f0fb85a0115cfd0e88
parent4f980de2c7a2ec29c561bca815c37879a18cc2b3 (diff)
downloadpardes-35cecf26b06cf50d9827056da99d883ca975c39b.tar.gz
pardes-35cecf26b06cf50d9827056da99d883ca975c39b.zip
load builtin commands from the user config before rendering
-rw-r--r--build.zig9
-rw-r--r--docs/config.md25
-rw-r--r--next-steps.txt2
-rw-r--r--src/config.zig4
-rw-r--r--src/main.zig5
-rw-r--r--src/pardes.zig118
-rw-r--r--src/user_config.zig97
-rw-r--r--test/image_harness.zig4
-rw-r--r--test/snapshot.zig4
9 files changed, 244 insertions, 24 deletions
diff --git a/build.zig b/build.zig
index 40058407..a462c581 100644
--- a/build.zig
+++ b/build.zig
@@ -743,6 +743,15 @@ pub fn build(b: *std.Build) void {
}) });
const unit_step = b.step("unit-test", "run the modal.zig + crt.zig + deck replay + core unit tests");
unit_step.dependOn(&b.addRunArtifact(unit).step);
+ // Native config path/loading is std-only and has its own hermetic
+ // filesystem/environment tests. The core receives bytes through
+ // Options, so this IO module stays outside the core module graph.
+ const user_config_test = b.addTest(.{ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/user_config.zig"),
+ }) });
+ unit_step.dependOn(&b.addRunArtifact(user_config_test).step);
// crt.zig is pure std — the mouse-mapping-vs-shader-formula test
const crt_test = b.addTest(.{ .root_module = b.createModule(.{
.target = target,
diff --git a/docs/config.md b/docs/config.md
new file mode 100644
index 00000000..b3eee911
--- /dev/null
+++ b/docs/config.md
@@ -0,0 +1,25 @@
+# Startup configuration
+
+Native pardes builds read a per-user `pardes` file before the first frame:
+
+- Unix: `$XDG_CONFIG_HOME/pardes`, falling back to `~/.config/pardes`.
+- macOS: `$XDG_CONFIG_HOME/pardes` when that variable is set, otherwise
+ `~/Library/Application Support/pardes`.
+- Windows: `%LOCALAPPDATA%\pardes`, with `%USERPROFILE%\AppData\Local\pardes`
+ as the fallback.
+
+The browser build has no local user-config path and does not load this file.
+
+The format is one existing builtin command per line, using the same spelling
+and argument parsing as commands executed inside pardes:
+
+```text
+Theme acme
+Font DejaVuSansMono-Regular
+```
+
+Blank, unknown, malformed, or unsuccessful lines are ignored silently, and a
+bad line does not prevent later lines from running. Top-level text that is not
+a builtin is not sent to a shell. (`Exec ...` remains an ordinary builtin and
+therefore keeps its normal behavior.) Key bindings remain compile-time choices
+in `src/config.zig`; this startup file does not remap them.
diff --git a/next-steps.txt b/next-steps.txt
index 0908b107..1a2451db 100644
--- a/next-steps.txt
+++ b/next-steps.txt
@@ -1,6 +1,6 @@
+ Esc in normal mode toggles focus (the same functionality as spc+w+t).
+ sdl backend no longer grabs mouse focus
-- implement a real basic config file at ~/.config/pardes (use zig stdlib to locate these config dirs) which will execute the builtins in each line; like Font abc, Theme asdfd, this will execute before rendering so that the application won't flash or anything when styling changes. also if there's an error with a command, it will silently fail.
++ implement a real basic config file at ~/.config/pardes (use zig stdlib to locate these config dirs) which will execute the builtins in each line; like Font abc, Theme asdfd, this will execute before rendering so that the application won't flash or anything when styling changes. also if there's an error with a command, it will silently fail.
- implement ipc comms via unix sockets, this will be used to deatach and reattach to a pardes session .
- Add the New builtin that will open a new empty tempfile on the column that called it.
- reorder the top tagline so that Kill goes last and most useful commands come first.
diff --git a/src/config.zig b/src/config.zig
index fed4994a..6748a438 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -13,6 +13,10 @@
//! syntax. PART 3 is the helix keymap, which is under a differential-testing
//! contract — see the banner there before touching it.
//!
+//! These COMPILED bindings are distinct from the small startup command file
+//! described in docs/config.md. That file can run builtins such as `Theme`
+//! and `Font`; it does not replace or mutate this keymap at runtime.
+//!
//! What is NOT a binding: a named key's own identity. Insert mode's Backspace,
//! Delete, Enter and Tab are dispatched on `Key.<name>` in handleInsert and
//! stay there — Backspace deleting backwards is what the key IS, not a choice
diff --git a/src/main.zig b/src/main.zig
index 9822ea3c..abf9b22d 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -152,6 +152,10 @@ fn nativeMain(init: std.process.Init) !void {
return error.BadArgs;
}
}
+ // Native shells opt into the user config; the sans-IO core and web keep
+ // Options' null default. Read it before entering either frontend so every
+ // builtin has run before that frontend can render its first frame.
+ opts.startup_config = @import("user_config.zig").load(init.io, arena, init.environ_map);
switch (pardes.platform) {
.tty => try @import("tty/tty.zig").run(init, opts),
.gui => try @import("gui/gui.zig").run(init, opts),
@@ -181,6 +185,7 @@ fn parseCtrlKey(raw: []const u8) ?u21 {
// file's tests and no further: fonts.zig is imported by both gui.zig and
// builtins.zig and still needs its own line here.
test {
+ _ = @import("user_config.zig");
if (comptime pardes.platform == .gui) {
_ = @import("gui/gui.zig");
_ = @import("gui/fonts.zig");
diff --git a/src/pardes.zig b/src/pardes.zig
index 7b359f0d..6156c56f 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -607,6 +607,47 @@ test "PdfFit builtin and leader path follow the MuPDF feature gate" {
}
}
+test "startup config runs builtin lines in order and isolates bad lines" {
+ const p = try Pardes.init(std.testing.allocator, .{
+ .startup_config =
+ \\Theme dark
+ \\DefinitelyNotABuiltin
+ \\Kill trailing-garbage
+ \\Theme no-such-theme
+ \\Theme acme
+ ,
+ });
+ defer p.deinit();
+
+ // The last valid command wins even after unknown, malformed, and
+ // well-formed-but-failing lines. `Kill trailing-garbage` must not be
+ // accepted as Kill, nor fall through to the shell from config.
+ try std.testing.expectEqualStrings("acme", p.theme().name);
+ try std.testing.expect(!p.quit);
+ for (p.effects.items) |effect| switch (effect) {
+ .write => |w| {
+ try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "DefinitelyNotABuiltin") == null);
+ try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "Kill") == null);
+ },
+ else => {},
+ };
+}
+
+test "startup config overrides restored styling before initFromDump returns" {
+ const source = try Pardes.init(std.testing.allocator, .{ .startup_config = "Theme dark\n" });
+ defer source.deinit();
+ try std.testing.expectEqualStrings("dark", source.theme().name);
+ try source.dumpState();
+
+ const restored = try Pardes.initFromDump(
+ std.testing.allocator,
+ .{ .startup_config = "Theme acme\n" },
+ source.dump_out.?,
+ );
+ defer restored.deinit();
+ try std.testing.expectEqualStrings("acme", restored.theme().name);
+}
+
/// How a `Chord` is SPELLED in the index. The named keys come from Key's OWN
/// declarations rather than a table beside them — a new special key names
/// itself here, and a wrong name is impossible because there is only one. The
@@ -1855,6 +1896,11 @@ pub const Options = struct {
tty_toggle: u21 = config.tty_toggle_default,
/// load a dump of another instance instead of spawning shells (acme -l)
load_path: ?[]const u8 = null,
+ /// Native main fills this with the contents of the per-user config file.
+ /// Keeping discovery out of the core makes constructors and web builds
+ /// deterministic; when present, each line is dispatched as a builtin
+ /// before init returns and therefore before any frontend can render.
+ startup_config: ?[]const u8 = null,
/// Initial grid. Shell contract: for LIVE sessions leave these at the
/// defaults and deliver the real size as the first resize EVENT — the core
/// defers each shell's greeting until after a resize (so `ls` wraps to the
@@ -2035,6 +2081,8 @@ pub const Pardes = struct {
p.col_terms[0][0] = 0;
}
p.sync();
+ p.applyStartupConfig();
+ p.sync();
return p;
}
@@ -8096,16 +8144,44 @@ pub const Pardes = struct {
/// does not.
pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize {
const pane = p.panes[id] orelse return null;
- var cmd = std.mem.trim(u8, txt, " \t\r\n");
- // `` @`ls -la` `` is the same command line wearing the syntax that
- // makes it clickable inside ordinary prose: unwrap and carry on. The
- // loop (rather than one peel) costs nothing and is honest about the
- // form nesting.
- while (config.commandWord(cmd)) |inner| cmd = std.mem.trim(u8, inner, " \t\r\n");
+ const cmd = commandText(txt);
if (cmd.len == 0) return null;
+ if (p.executeBuiltinLine(id, cmd)) return null;
if (p.exec_depth >= max_exec_depth) return null;
p.exec_depth += 1;
defer p.exec_depth -= 1;
+ // Anything not in the builtin vocabulary is a command line typed at
+ // a shell. Startup config calls executeBuiltinLine directly and never
+ // reaches this fallback.
+ // terminal: run in itself. file pane: run in a shell in the file's
+ // directory (found by cwd, else freshly forked).
+ const dst = (if (pane.file) |f|
+ p.ttyForDir(std.fs.path.dirname(f.path) orelse "/")
+ else if (hasPdf(pane))
+ p.ttyForDir(paneDir(pane))
+ else
+ id) orelse return null;
+ term_pane.padOutputBelowEdits(p, dst);
+ p.emitWrite(dst, cmd);
+ p.emitWrite(dst, "\r");
+ return dst;
+ }
+
+ /// Trim and unwrap clickable command-word notation once for every command
+ /// consumer, so startup lines and interactive Exec speak the same syntax.
+ fn commandText(txt: []const u8) []const u8 {
+ var cmd = std.mem.trim(u8, txt, " \t\r\n");
+ while (config.commandWord(cmd)) |inner| cmd = std.mem.trim(u8, inner, " \t\r\n");
+ return cmd;
+ }
+
+ /// Parse and dispatch exactly one builtin command. This is the canonical
+ /// builtin path used both by ordinary Exec and by startup configuration;
+ /// unlike execute(), it deliberately has no external-shell fallback.
+ /// False means blank, malformed, unknown, or recursion-limited.
+ pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool {
+ const cmd = commandText(txt);
+ if (cmd.len == 0 or p.exec_depth >= max_exec_depth) return false;
// The builtins that take an ARGUMENT match their name with a TAIL:
// `Restore <path>`, `Find <pat>`, `Grep <pat>`, `Rename <name>`,
// `WsSymbols <sym>`, `Theme <name>`, `Font <name>` (gui only), and the
@@ -8128,23 +8204,17 @@ pub const Pardes = struct {
},
};
};
- if (bi) |b| {
- const typed = if (sp) |s| std.mem.trim(u8, cmd[s + 1 ..], " \t") else "";
- p.runBuiltin(b, id, cmd, if (typed.len > 0) typed else null);
- return null;
- }
- // terminal: run in itself. file pane: run in a shell in the file's
- // directory (found by cwd, else freshly forked).
- const dst = (if (pane.file) |f|
- p.ttyForDir(std.fs.path.dirname(f.path) orelse "/")
- else if (hasPdf(pane))
- p.ttyForDir(paneDir(pane))
- else
- id) orelse return null;
- term_pane.padOutputBelowEdits(p, dst);
- p.emitWrite(dst, cmd);
- p.emitWrite(dst, "\r");
- return dst;
+ const b = bi orelse return false;
+ p.exec_depth += 1;
+ defer p.exec_depth -= 1;
+ const typed = if (sp) |s| std.mem.trim(u8, cmd[s + 1 ..], " \t") else "";
+ p.runBuiltin(b, id, cmd, if (typed.len > 0) typed else null);
+ return true;
+ }
+
+ fn applyStartupConfig(p: *Pardes) void {
+ var lines = std.mem.splitScalar(u8, p.opts.startup_config orelse return, '\n');
+ while (lines.next()) |line| _ = p.executeBuiltinLine(p.active, line);
}
/// Run a builtin on pane `id`. `txt` is the executed text (Restore reads
@@ -8508,6 +8578,8 @@ pub const Pardes = struct {
}
p.active = @min(st.active, MAX_PANES - 1);
p.sync();
+ p.applyStartupConfig();
+ p.sync();
return p;
}
diff --git a/src/user_config.zig b/src/user_config.zig
new file mode 100644
index 00000000..455e8a3c
--- /dev/null
+++ b/src/user_config.zig
@@ -0,0 +1,97 @@
+//! Native startup configuration discovery and loading.
+//!
+//! The file is named `pardes` inside the platform's per-user configuration
+//! directory. Discovery is deliberately outside the sans-IO core: native
+//! launchers opt in by putting the bytes in `Options.startup_config`, while
+//! tests and the browser keep the default `null`.
+
+const std = @import("std");
+const builtin = @import("builtin");
+
+const max_bytes = 1024 * 1024;
+
+/// Resolve the native per-user config file with stdlib environment and path
+/// APIs. XDG_CONFIG_HOME is accepted only when absolute, as required by the
+/// XDG base-directory specification; an empty/relative value falls back.
+pub fn path(gpa: std.mem.Allocator, env: *const std.process.Environ.Map) !?[]u8 {
+ if (builtin.os.tag == .windows) {
+ if (nonEmpty(env.get("LOCALAPPDATA"))) |base|
+ return try std.fs.path.join(gpa, &.{ base, "pardes" });
+ if (nonEmpty(env.get("USERPROFILE"))) |home|
+ return try std.fs.path.join(gpa, &.{ home, "AppData", "Local", "pardes" });
+ return null;
+ }
+
+ if (xdgBase(env)) |base|
+ return try std.fs.path.join(gpa, &.{ base, "pardes" });
+
+ const home = nonEmpty(env.get("HOME")) orelse return null;
+ if (builtin.os.tag == .macos)
+ return try std.fs.path.join(gpa, &.{ home, "Library", "Application Support", "pardes" });
+ return try std.fs.path.join(gpa, &.{ home, ".config", "pardes" });
+}
+
+/// Missing, unreadable, oversized, or otherwise unusable config is simply no
+/// config. The arena passed by main owns successful bytes for the process.
+pub fn load(
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ env: *const std.process.Environ.Map,
+) ?[]u8 {
+ const config_path = path(gpa, env) catch return null;
+ defer if (config_path) |p| gpa.free(p);
+ const p = config_path orelse return null;
+ return std.Io.Dir.cwd().readFileAlloc(io, p, gpa, .limited(max_bytes)) catch null;
+}
+
+fn nonEmpty(value: ?[]const u8) ?[]const u8 {
+ const v = value orelse return null;
+ return if (v.len == 0) null else v;
+}
+
+fn xdgBase(env: *const std.process.Environ.Map) ?[]const u8 {
+ const value = nonEmpty(env.get("XDG_CONFIG_HOME")) orelse return null;
+ return if (std.fs.path.isAbsolute(value)) value else null;
+}
+
+test "config path honors XDG and rejects a relative XDG directory" {
+ if (builtin.os.tag == .windows) return;
+
+ var env: std.process.Environ.Map = .init(std.testing.allocator);
+ defer env.deinit();
+ try env.put("HOME", "/home/pardes-test");
+ try env.put("XDG_CONFIG_HOME", "/var/tmp/pardes-xdg");
+
+ const xdg = (try path(std.testing.allocator, &env)).?;
+ defer std.testing.allocator.free(xdg);
+ try std.testing.expectEqualStrings("/var/tmp/pardes-xdg/pardes", xdg);
+
+ try env.put("XDG_CONFIG_HOME", "relative/config");
+ const fallback = (try path(std.testing.allocator, &env)).?;
+ defer std.testing.allocator.free(fallback);
+ const expected = if (builtin.os.tag == .macos)
+ "/home/pardes-test/Library/Application Support/pardes"
+ else
+ "/home/pardes-test/.config/pardes";
+ try std.testing.expectEqualStrings(expected, fallback);
+}
+
+test "config loader is silent when missing and returns exact file bytes" {
+ if (builtin.os.tag == .windows) return;
+
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var base_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const base_len = try tmp.dir.realPath(std.testing.io, &base_buf);
+
+ var env: std.process.Environ.Map = .init(std.testing.allocator);
+ defer env.deinit();
+ try env.put("XDG_CONFIG_HOME", base_buf[0..base_len]);
+
+ try std.testing.expect(load(std.testing.io, std.testing.allocator, &env) == null);
+ const source = "Theme dark\nUnknown command\nTheme acme\n";
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pardes", .data = source });
+ const bytes = load(std.testing.io, std.testing.allocator, &env).?;
+ defer std.testing.allocator.free(bytes);
+ try std.testing.expectEqualStrings(source, bytes);
+}
diff --git a/test/image_harness.zig b/test/image_harness.zig
index 390c14e8..bd465e1c 100644
--- a/test/image_harness.zig
+++ b/test/image_harness.zig
@@ -40,6 +40,10 @@ pub fn main(init: std.process.Init) !void {
if (libc.mkdir(base, 0o755) != 0 and libc.errno(-1) != .EXIST) return error.MkdirFailed;
defer cleanupBase(arena, base);
_ = setenv("HOME", base, 1);
+ // Do not let an exported developer XDG_CONFIG_HOME style or mutate the
+ // native process under test; this nonexistent directory is test-owned.
+ const config_home = try std.fmt.allocPrintSentinel(arena, "{s}/.config", .{base}, 0);
+ _ = setenv("XDG_CONFIG_HOME", config_home, 1);
_ = setenv("HISTFILE", "/dev/null", 1);
_ = setenv("TERM", "xterm-256color", 1);
_ = setenv("LC_ALL", "C", 1);
diff --git a/test/snapshot.zig b/test/snapshot.zig
index 19b65a02..7c00b97c 100644
--- a/test/snapshot.zig
+++ b/test/snapshot.zig
@@ -307,6 +307,10 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
// deterministic shell: fixed prompt, no history, no user rc leakage
try eh.writeFile(try std.fmt.allocPrintSentinel(arena, "{s}/.bashrc", .{home}, 0), "PS1='$ '\nHISTFILE=\n");
_ = setenv("HOME", try arena.dupeZ(u8, home), 1);
+ // Keep startup configuration hermetic even when the developer exports an
+ // XDG directory that points outside this per-snapshot fake home.
+ const config_home = try std.fmt.allocPrintSentinel(arena, "{s}/.config", .{home}, 0);
+ _ = setenv("XDG_CONFIG_HOME", config_home, 1);
_ = setenv("TERM", "xterm-256color", 1);
_ = setenv("LC_ALL", "C", 1);
// durations cannot live in a golden; the lsp introspection views print