summaryrefslogtreecommitdiff
path: root/src
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 /src
parent4f980de2c7a2ec29c561bca815c37879a18cc2b3 (diff)
downloadpardes-35cecf26b06cf50d9827056da99d883ca975c39b.tar.gz
pardes-35cecf26b06cf50d9827056da99d883ca975c39b.zip
load builtin commands from the user config before rendering
Diffstat (limited to 'src')
-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
4 files changed, 201 insertions, 23 deletions
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);
+}