diff options
| -rw-r--r-- | build.zig | 9 | ||||
| -rw-r--r-- | docs/config.md | 25 | ||||
| -rw-r--r-- | next-steps.txt | 2 | ||||
| -rw-r--r-- | src/config.zig | 4 | ||||
| -rw-r--r-- | src/main.zig | 5 | ||||
| -rw-r--r-- | src/pardes.zig | 118 | ||||
| -rw-r--r-- | src/user_config.zig | 97 | ||||
| -rw-r--r-- | test/image_harness.zig | 4 | ||||
| -rw-r--r-- | test/snapshot.zig | 4 |
9 files changed, 244 insertions, 24 deletions
@@ -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 |
