summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-01 19:56:21 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitcc806b11ba880d1bd326e3f1f9e69f7539c22395 (patch)
tree3b200ed4e46e2c80d2762ae2efd45bf87413133d
parentf3618a5a3151e02db2c1e2280ab42d48c35eeba9 (diff)
downloadpardes-cc806b11ba880d1bd326e3f1f9e69f7539c22395.tar.gz
pardes-cc806b11ba880d1bd326e3f1f9e69f7539c22395.zip
more treesitter grammars
-rw-r--r--build.zig54
-rw-r--r--build.zig.zon94
-rw-r--r--main.zig244
-rw-r--r--term.zig4
-rw-r--r--tests.zig50
-rw-r--r--tutor.txt49
6 files changed, 429 insertions, 66 deletions
diff --git a/build.zig b/build.zig
index f6647ed5..fbe8c67a 100644
--- a/build.zig
+++ b/build.zig
@@ -101,25 +101,57 @@ pub fn build(b: *std.Build) void {
const tree_sitter = b.dependency("tree_sitter", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("tree-sitter", tree_sitter.module("tree_sitter"));
- const grammars = .{
- .{ "c", "ts_c", false },
- .{ "rust", "ts_rust", true },
- .{ "zig", "ts_zig", false },
+ const Grammar = struct {
+ name: []const u8,
+ dep: []const u8,
+ src: []const u8 = "src",
+ scanner: bool = false,
+ query: []const u8 = "queries/highlights.scm",
+ local_query: ?[]const u8 = null,
+ };
+ const grammars = [_]Grammar{
+ .{ .name = "ada", .dep = "ts_ada" },
+ .{ .name = "bash", .dep = "ts_bash", .scanner = true },
+ .{ .name = "c", .dep = "ts_c" },
+ .{ .name = "c_sharp", .dep = "ts_c_sharp", .scanner = true },
+ .{ .name = "clojure", .dep = "ts_clojure" },
+ .{ .name = "cpp", .dep = "ts_cpp", .scanner = true },
+ .{ .name = "css", .dep = "ts_css", .scanner = true },
+ .{ .name = "elixir", .dep = "ts_elixir", .scanner = true },
+ .{ .name = "erlang", .dep = "ts_erlang", .scanner = true },
+ .{ .name = "fortran", .dep = "ts_fortran", .scanner = true },
+ .{ .name = "go", .dep = "ts_go" },
+ .{ .name = "haskell", .dep = "ts_haskell", .scanner = true },
+ .{ .name = "html", .dep = "ts_html", .scanner = true },
+ .{ .name = "java", .dep = "ts_java" },
+ .{ .name = "javascript", .dep = "ts_javascript", .scanner = true },
+ .{ .name = "json", .dep = "ts_json" },
+ .{ .name = "kotlin", .dep = "ts_kotlin", .scanner = true },
+ .{ .name = "ocaml", .dep = "ts_ocaml", .src = "grammars/ocaml/src", .scanner = true },
+ .{ .name = "pascal", .dep = "ts_pascal" },
+ .{ .name = "php", .dep = "ts_php", .src = "php/src", .scanner = true },
+ .{ .name = "powershell", .dep = "ts_powershell", .scanner = true },
+ .{ .name = "python", .dep = "ts_python", .scanner = true },
+ .{ .name = "ruby", .dep = "ts_ruby", .scanner = true },
+ .{ .name = "rust", .dep = "ts_rust", .scanner = true },
+ .{ .name = "scala", .dep = "ts_scala", .scanner = true },
+ .{ .name = "zig", .dep = "ts_zig" },
};
const ts_queries = b.addOptions();
inline for (grammars) |g| {
- const dep = b.dependency(g[1], .{});
- const query = std.Io.Dir.cwd().readFileAlloc(io, dep.path("queries/highlights.scm").getPath(b), b.allocator, .limited(0x100000)) catch @panic("read " ++ g[0] ++ " highlights");
- ts_queries.addOption([]const u8, g[0] ++ "_highlights", query);
+ const dep = b.dependency(g.dep, .{});
+ const query_path = if (g.local_query) |p| b.path(p) else dep.path(g.query);
+ const query = std.Io.Dir.cwd().readFileAlloc(io, query_path.getPath(b), b.allocator, .limited(0x100000)) catch @panic("read " ++ g.name ++ " highlights");
+ ts_queries.addOption([]const u8, g.name ++ "_highlights", query);
const lib = b.addLibrary(.{
- .name = "tree-sitter-" ++ g[0],
+ .name = "tree-sitter-" ++ g.name,
.root_module = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true }),
.linkage = .static,
});
- lib.root_module.addCSourceFile(.{ .file = dep.path("src/parser.c") });
- if (g[2]) lib.root_module.addCSourceFile(.{ .file = dep.path("src/scanner.c") });
- lib.root_module.addIncludePath(dep.path("src"));
+ lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/parser.c") });
+ if (g.scanner) lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/scanner.c") });
+ lib.root_module.addIncludePath(dep.path(g.src));
exe.root_module.linkLibrary(lib);
}
exe.root_module.addOptions("ts_queries", ts_queries);
diff --git a/build.zig.zon b/build.zig.zon
index 3c72c8a4..2d5a2b3a 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -24,7 +24,7 @@
.url = "git+https://github.com/zig-gamedev/zstbi#3813f5f113b26f644cfa01a30155cc76b3526e91",
.hash = "zstbi-0.11.0-dev-L0Ea_3GWBwAh7m54XX055vwLKPTPij5hyqyeq92MXPE2",
},
- // tree-sitter: Zig bindings + the C runtime, plus three grammars (parser.c
+ // tree-sitter: Zig bindings + the C runtime, plus grammars (parser.c
// we link, highlights.scm we read at build time into the ts_queries module).
.tree_sitter = .{
.url = "git+https://github.com/tree-sitter/zig-tree-sitter#b55d4977d61330390fc733944da06106ff972660",
@@ -42,6 +42,98 @@
.url = "git+https://github.com/maxxnino/tree-sitter-zig#a80a6e9be81b33b182ce6305ae4ea28e29211bd5",
.hash = "N-V-__8AADLAWgBszUZPv5YTGyO5ZNyxNcuuR8MB3mIMJrZf",
},
+ .ts_python = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-python#26855eabccb19c6abf499fbc5b8dc7cc9ab8bc64",
+ .hash = "N-V-__8AAOiqPgC9tH5tYSAYNsMWLoPiSxcmkHvIhMnezomx",
+ },
+ .ts_javascript = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-javascript#58404d8cf191d69f2674a8fd507bd5776f46cb11",
+ .hash = "N-V-__8AANlfOQDpOAP-6YMQeJ2nQetX1OpUshQr903bnIHU",
+ },
+ .ts_go = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-go#2346a3ab1bb3857b48b29d779a1ef9799a248cd7",
+ .hash = "N-V-__8AAM9cIgDttbrvbwLWFB0JPplu_2KtCWu7__79EjIT",
+ },
+ .ts_json = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-json#001c28d7a29832b06b0e831ec77845553c89b56d",
+ .hash = "N-V-__8AAHNaAgD6kjK3HX6lAf89chPqS5zIm0tLpvBQLOtX",
+ },
+ .ts_html = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-html#73a3947324f6efddf9e17c0ea58d454843590cc0",
+ .hash = "N-V-__8AAEmOAwDdgRRlet5t1CEdpX1YMCbfhFiIbj_vsBH9",
+ },
+ .ts_css = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-css#dda5cfc5722c429eaba1c910ca32c2c0c5bb1a3f",
+ .hash = "N-V-__8AAKwvEACP3YMpMDoRyyZPOdDN1n-zdTZRWTc3uufz",
+ },
+ .ts_bash = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-bash#a06c2e4415e9bc0346c6b86d401879ffb44058f7",
+ .hash = "N-V-__8AANjenwCy_cXlIbU8nqBzGxMlEHVokLZJYrmIXtoz",
+ },
+ .ts_cpp = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-cpp#8b5b49eb196bec7040441bee33b2c9a4838d6967",
+ .hash = "N-V-__8AAMePmwGeojPhBaBC10aGwaQIOU4vg1H17IWakWs-",
+ },
+ .ts_java = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-java#e10607b45ff745f5f876bfa3e94fbcc6b44bdc11",
+ .hash = "N-V-__8AAGrhLgBgxGFnDv-tHxMarGDRT4VTig1IIX1rcBdc",
+ },
+ .ts_c_sharp = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-c-sharp#af29416d729b7a6603101b513604392d8f675e3b",
+ .hash = "N-V-__8AALpc-AFDJNotrh7YgiXce1oXC9ub9SuD-Enwt7rN",
+ },
+ .ts_php = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-php#38216983c07bf9e1b56e16acde53b25adaeab61c",
+ .hash = "N-V-__8AABgQ6QCTufRtmuKAuAvX1ty8ZouX1jZ17wGFcx6I",
+ },
+ .ts_ruby = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-ruby#ad907a69da0c8a4f7a943a7fe012712208da6dee",
+ .hash = "N-V-__8AAB_S8QDTw_FIWZfx_eKtcAvrPJuTP12QEMVN2LDS",
+ },
+ .ts_scala = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-scala#4d081d98670ff6e98ca42c085294fc75eec15e1d",
+ .hash = "N-V-__8AAJlN0gFxPUfpXqmIgQLEXkmXsrvo3caVWINVJdTb",
+ },
+ .ts_kotlin = .{
+ .url = "git+https://github.com/fwcd/tree-sitter-kotlin#c8ac3d2627240160b999a2c100de3babbdb8f419",
+ .hash = "N-V-__8AAKSpGALvICLMOMcl4S_ypQBib_euVyf_B_9DP08A",
+ },
+ .ts_haskell = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-haskell#0975ef72fc3c47b530309ca93937d7d143523628",
+ .hash = "N-V-__8AALWgQgGf5l7eg-Dwi24SJszCffzN4_mWEtv3e3oI",
+ },
+ .ts_ocaml = .{
+ .url = "git+https://github.com/tree-sitter/tree-sitter-ocaml#527d62ef0f24ce0d97fbedf004921d75d0a7e086",
+ .hash = "N-V-__8AAGaGxwK7r2vy00a8b4MDEm0eudoNzkzu1WM01h7Q",
+ },
+ .ts_erlang = .{
+ .url = "git+https://github.com/WhatsApp/tree-sitter-erlang#836aa2b6c3af2c7cef3f84049b0ed6d44485a870",
+ .hash = "N-V-__8AAAVWKgC44vrL1kvl8SLUnqNkFU4guLAaXK73RmYK",
+ },
+ .ts_elixir = .{
+ .url = "git+https://github.com/elixir-lang/tree-sitter-elixir#7937d3b4d65fa574163cfa59394515d3c1cf16f4",
+ .hash = "N-V-__8AAHFBzAB5XFJyJvMulqqafphEQOoizic5m6WFfy6j",
+ },
+ .ts_clojure = .{
+ .url = "git+https://github.com/sogaiu/tree-sitter-clojure#e43eff80d17cf34852dcd92ca5e6986d23a7040f",
+ .hash = "N-V-__8AAAnoDwDKLr63cSQ_IHEojhLIanmbGIkrZNQSUM5_",
+ },
+ .ts_fortran = .{
+ .url = "git+https://github.com/stadelmanma/tree-sitter-fortran#7edacd2b21aa80057d9725384a1304a1c758e0f8",
+ .hash = "N-V-__8AAJ0zQgKKtzb0-wDQjY1pewE7RXmpAHVsKuwLT0e3",
+ },
+ .ts_ada = .{
+ .url = "git+https://github.com/briot/tree-sitter-ada#6b58259a08b1a22ba0247a7ce30be384db618da6",
+ .hash = "N-V-__8AAHNJMgAgTa051RgoAs48ohsOgCTsM34tTxoj80CE",
+ },
+ .ts_pascal = .{
+ .url = "git+https://github.com/Isopod/tree-sitter-pascal#042119eca2e18a60e56317fb06ee3ba5c32cb447",
+ .hash = "N-V-__8AAMmnUgA4hx9Tv_Vl5uDuzQ8r4gSH6J1CuwlZ5YOm",
+ },
+ .ts_powershell = .{
+ .url = "git+https://github.com/airbus-cert/tree-sitter-powershell#d398441825243b00e317e87e1829b9d6a3e54ce0",
+ .hash = "N-V-__8AAKzlUACji5nMCtCC_dgDtF1rEg1qoduDm3MT5B5H",
+ },
},
.minimum_zig_version = "0.16.0",
.paths = .{""},
diff --git a/main.zig b/main.zig
index bf064f66..33bc5345 100644
--- a/main.zig
+++ b/main.zig
@@ -9,8 +9,31 @@ const ts_queries = @import("ts_queries");
// grammars whose parser.c we link (see build.zig). The query strings ride along
// in the ts_queries module.
+extern fn tree_sitter_ada() callconv(.c) *const ts.Language;
+extern fn tree_sitter_bash() callconv(.c) *const ts.Language;
extern fn tree_sitter_c() callconv(.c) *const ts.Language;
+extern fn tree_sitter_c_sharp() callconv(.c) *const ts.Language;
+extern fn tree_sitter_clojure() callconv(.c) *const ts.Language;
+extern fn tree_sitter_cpp() callconv(.c) *const ts.Language;
+extern fn tree_sitter_css() callconv(.c) *const ts.Language;
+extern fn tree_sitter_elixir() callconv(.c) *const ts.Language;
+extern fn tree_sitter_erlang() callconv(.c) *const ts.Language;
+extern fn tree_sitter_fortran() callconv(.c) *const ts.Language;
+extern fn tree_sitter_go() callconv(.c) *const ts.Language;
+extern fn tree_sitter_haskell() callconv(.c) *const ts.Language;
+extern fn tree_sitter_html() callconv(.c) *const ts.Language;
+extern fn tree_sitter_java() callconv(.c) *const ts.Language;
+extern fn tree_sitter_javascript() callconv(.c) *const ts.Language;
+extern fn tree_sitter_json() callconv(.c) *const ts.Language;
+extern fn tree_sitter_kotlin() callconv(.c) *const ts.Language;
+extern fn tree_sitter_ocaml() callconv(.c) *const ts.Language;
+extern fn tree_sitter_pascal() callconv(.c) *const ts.Language;
+extern fn tree_sitter_php() callconv(.c) *const ts.Language;
+extern fn tree_sitter_powershell() callconv(.c) *const ts.Language;
+extern fn tree_sitter_python() callconv(.c) *const ts.Language;
+extern fn tree_sitter_ruby() callconv(.c) *const ts.Language;
extern fn tree_sitter_rust() callconv(.c) *const ts.Language;
+extern fn tree_sitter_scala() callconv(.c) *const ts.Language;
extern fn tree_sitter_zig() callconv(.c) *const ts.Language;
// A TUI can't let std.log hit stderr — it's the same tty as our alt-screen, so the
@@ -98,6 +121,66 @@ fn setYank(gpa: std.mem.Allocator, text: []const u8) void {
yank_dirty = true;
}
+const default_tty_toggle: u21 = 'b';
+
+const Cli = struct {
+ tty_only: bool = false,
+ tty_toggle: u21 = default_tty_toggle,
+ help: bool = false,
+};
+
+const help_text =
+ \\Usage: pardes [options]
+ \\
+ \\Options:
+ \\ --tty start as one shell pane already in tty mode
+ \\ --tty-toggle <key> use Ctrl-<key> to enter/leave tty mode
+ \\ --tty-toggle=<key> same as above; accepts b, Ctrl-b, or ^b
+ \\ -h, --help show this help and exit
+ \\
+;
+
+fn printHelp(io: std.Io) !void {
+ try std.Io.File.stdout().writeStreamingAll(io, help_text);
+}
+
+fn parseCtrlKey(raw: []const u8) ?u21 {
+ var value = std.mem.trim(u8, raw, " \t\r\n");
+ if (value.len >= 5 and std.ascii.eqlIgnoreCase(value[0..5], "ctrl-")) {
+ value = value[5..];
+ } else if (value.len >= 5 and std.ascii.eqlIgnoreCase(value[0..5], "ctrl+")) {
+ value = value[5..];
+ } else if (value.len == 2 and value[0] == '^') {
+ value = value[1..];
+ }
+ if (value.len != 1) return null;
+ const c = std.ascii.toLower(value[0]);
+ if (c < 'a' or c > 'z' or c == 'c') return null;
+ return c;
+}
+
+fn parseCli(args: []const [:0]const u8) !Cli {
+ var cli: Cli = .{};
+ var i: usize = 1;
+ while (i < args.len) : (i += 1) {
+ const a = args[i];
+ if (std.mem.eql(u8, a, "--tty")) {
+ cli.tty_only = true;
+ } else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) {
+ cli.help = true;
+ } else if (std.mem.startsWith(u8, a, "--tty-toggle=")) {
+ cli.tty_toggle = parseCtrlKey(a["--tty-toggle=".len..]) orelse return error.BadArgs;
+ } else if (std.mem.eql(u8, a, "--tty-toggle")) {
+ i += 1;
+ if (i >= args.len) return error.BadArgs;
+ cli.tty_toggle = parseCtrlKey(args[i]) orelse return error.BadArgs;
+ } else {
+ return error.BadArgs;
+ }
+ }
+ return cli;
+}
+
const Loop = vaxis.Loop(@TypeOf(Command.value));
// layout constants
@@ -251,9 +334,10 @@ fn synStyle(s: Syn) ?struct { fg: [3]u8, bold: bool } {
// Builtins. Executing the name (middle-click / Tab) runs the action through the ONE
// dispatcher (onSelect + actOnSelection), no matter where the name is shown — a pane
// body, a file, the tagbar. onSelect matches against the whole list, so a name works
-// wherever it appears. Window-specific ones (Del, Delcol) live in each pane's tag;
-// window-agnostic ones (Kill, Newcol, Tutor, Debug, Colors, NextColor) in the tagbar.
-const SelectAction = enum { none, send, newcol, delcol, del, tutor, kill, debug, colors, nextcolor };
+// wherever it appears. Window-specific ones (Save, Del, Delcol) live in each
+// pane's tag or can be typed/executed; window-agnostic ones (Kill, Newcol, Tutor,
+// Debug, Colors, NextColor) in the tagbar.
+const SelectAction = enum { none, send, newcol, delcol, del, save, tutor, kill, debug, colors, nextcolor };
const Builtin = struct { name: []const u8, action: SelectAction };
const builtins = [_]Builtin{
.{ .name = "Kill", .action = .kill },
@@ -262,13 +346,17 @@ const builtins = [_]Builtin{
.{ .name = "Debug", .action = .debug },
.{ .name = "Colors", .action = .colors },
.{ .name = "NextColor", .action = .nextcolor },
+ .{ .name = "Save", .action = .save },
.{ .name = "Del", .action = .del },
.{ .name = "Delcol", .action = .delcol },
};
-// per-pane tag commands (window-specific).
-const pane_builtins_str = "Del Delcol";
-// default editable tail (builtins with the leading gap after the cwd/path).
+// Default per-pane tag commands. Delcol remains a builtin command, but it is no
+// longer advertised in the default tag text.
+const pane_builtins_str = "Del";
+const file_pane_builtins_str = "Save Del";
+// default editable tails (builtins with the leading gap after the cwd/path).
const pane_tail = " " ++ pane_builtins_str;
+const file_pane_tail = " " ++ file_pane_builtins_str;
// the global tagbar: window-agnostic commands. Kill closes everything; Debug toggles
// the stats overlay, Colors the ansi/syntax recolor, NextColor cycles the theme.
const topbar_str = "Kill Newcol Tutor Debug Colors NextColor";
@@ -305,6 +393,12 @@ fn writeFile(path: [*:0]const u8, data: []const u8) void {
}
}
+fn saveFile(io: std.Io, f: *Term.File) !void {
+ var file = try std.Io.Dir.createFileAbsolute(io, f.path, .{});
+ defer file.close(io);
+ try file.writeStreamingAll(io, f.content);
+}
+
fn fork_pty(argv: []const ?[*:0]const u8, cwd: ?[*:0]const u8, ws: std.posix.winsize) struct { file: std.Io.File, pid: std.posix.pid_t } {
var master: c_int = undefined;
const pid = forkpty(&master, null, null, &ws);
@@ -431,9 +525,13 @@ fn tagPrefix(alloc: std.mem.Allocator, t: *Term) ![]u8 {
// The editable tail: the user's edited buffer once touched, else the default
// builtins. Empty for image panes (whole tag is live). Borrowed, not owned.
+fn defaultTail(t: *Term) []const u8 {
+ return if (t.file != null) file_pane_tail else pane_tail;
+}
+
fn curTail(t: *Term) []const u8 {
if (t.image != null) return "";
- return if (t.tag_init) t.tag_tail.items else pane_tail;
+ return if (t.tag_init) t.tag_tail.items else defaultTail(t);
}
// prefix + tail: the full rendered/selectable tag line. Caller frees with `alloc`.
@@ -453,7 +551,7 @@ fn enterTagEdit(gpa: std.mem.Allocator, alloc: std.mem.Allocator, t: *Term, clic
if (t.image != null) return; // images keep a live toggle tag, no editable tail
const pfx = tagPrefix(alloc, t) catch return;
if (!t.tag_init) {
- t.tag_tail.appendSlice(gpa, pane_tail) catch return;
+ t.tag_tail.appendSlice(gpa, defaultTail(t)) catch return;
t.tag_init = true;
}
t.tag_edit = true;
@@ -798,6 +896,13 @@ fn synFor(name: []const u8) Syn {
return .none;
}
+fn extIs(ext: []const u8, comptime choices: anytype) bool {
+ inline for (choices) |choice| {
+ if (std.ascii.eqlIgnoreCase(ext, choice)) return true;
+ }
+ return false;
+}
+
// Syntax-highlight `content` with tree-sitter: returns a gpa-owned byte per content
// byte (a `Syn` id; 0 = none), or empty when the extension has no grammar. Caller
// frees (stored in File.highlights; see term.zig). Best-effort — any parse/query
@@ -806,13 +911,82 @@ fn highlightFile(gpa: std.mem.Allocator, path: []const u8, content: []const u8)
const ext = std.fs.path.extension(path);
var lang: *const ts.Language = undefined;
var query_src: []const u8 = undefined;
- if (std.mem.eql(u8, ext, ".c") or std.mem.eql(u8, ext, ".h")) {
+ if (extIs(ext, .{ ".adb", ".ads", ".ada" })) {
+ lang = tree_sitter_ada();
+ query_src = ts_queries.ada_highlights;
+ } else if (extIs(ext, .{ ".sh", ".bash", ".zsh" })) {
+ lang = tree_sitter_bash();
+ query_src = ts_queries.bash_highlights;
+ } else if (extIs(ext, .{ ".c", ".h" })) {
lang = tree_sitter_c();
query_src = ts_queries.c_highlights;
- } else if (std.mem.eql(u8, ext, ".rs")) {
+ } else if (extIs(ext, .{ ".cs", ".csx" })) {
+ lang = tree_sitter_c_sharp();
+ query_src = ts_queries.c_sharp_highlights;
+ } else if (extIs(ext, .{ ".clj", ".cljs", ".cljc", ".edn" })) {
+ lang = tree_sitter_clojure();
+ query_src = ts_queries.clojure_highlights;
+ } else if (extIs(ext, .{ ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx" })) {
+ lang = tree_sitter_cpp();
+ query_src = ts_queries.cpp_highlights;
+ } else if (extIs(ext, .{".css"})) {
+ lang = tree_sitter_css();
+ query_src = ts_queries.css_highlights;
+ } else if (extIs(ext, .{ ".ex", ".exs" })) {
+ lang = tree_sitter_elixir();
+ query_src = ts_queries.elixir_highlights;
+ } else if (extIs(ext, .{ ".erl", ".hrl" })) {
+ lang = tree_sitter_erlang();
+ query_src = ts_queries.erlang_highlights;
+ } else if (extIs(ext, .{ ".f", ".for", ".ftn", ".f90", ".f95", ".f03", ".f08" })) {
+ lang = tree_sitter_fortran();
+ query_src = ts_queries.fortran_highlights;
+ } else if (extIs(ext, .{".go"})) {
+ lang = tree_sitter_go();
+ query_src = ts_queries.go_highlights;
+ } else if (extIs(ext, .{ ".hs", ".lhs" })) {
+ lang = tree_sitter_haskell();
+ query_src = ts_queries.haskell_highlights;
+ } else if (extIs(ext, .{ ".html", ".htm" })) {
+ lang = tree_sitter_html();
+ query_src = ts_queries.html_highlights;
+ } else if (extIs(ext, .{".java"})) {
+ lang = tree_sitter_java();
+ query_src = ts_queries.java_highlights;
+ } else if (extIs(ext, .{ ".js", ".jsx", ".mjs", ".cjs" })) {
+ lang = tree_sitter_javascript();
+ query_src = ts_queries.javascript_highlights;
+ } else if (extIs(ext, .{".json"})) {
+ lang = tree_sitter_json();
+ query_src = ts_queries.json_highlights;
+ } else if (extIs(ext, .{ ".kt", ".kts" })) {
+ lang = tree_sitter_kotlin();
+ query_src = ts_queries.kotlin_highlights;
+ } else if (extIs(ext, .{ ".ml", ".mli" })) {
+ lang = tree_sitter_ocaml();
+ query_src = ts_queries.ocaml_highlights;
+ } else if (extIs(ext, .{ ".pas", ".pp", ".p" })) {
+ lang = tree_sitter_pascal();
+ query_src = ts_queries.pascal_highlights;
+ } else if (extIs(ext, .{ ".php", ".phtml", ".php3", ".php4", ".php5" })) {
+ lang = tree_sitter_php();
+ query_src = ts_queries.php_highlights;
+ } else if (extIs(ext, .{ ".ps1", ".psm1", ".psd1" })) {
+ lang = tree_sitter_powershell();
+ query_src = ts_queries.powershell_highlights;
+ } else if (extIs(ext, .{ ".py", ".pyw" })) {
+ lang = tree_sitter_python();
+ query_src = ts_queries.python_highlights;
+ } else if (extIs(ext, .{ ".rb", ".rake" })) {
+ lang = tree_sitter_ruby();
+ query_src = ts_queries.ruby_highlights;
+ } else if (extIs(ext, .{".rs"})) {
lang = tree_sitter_rust();
query_src = ts_queries.rust_highlights;
- } else if (std.mem.eql(u8, ext, ".zig") or std.mem.eql(u8, ext, ".zon")) {
+ } else if (extIs(ext, .{ ".scala", ".sc" })) {
+ lang = tree_sitter_scala();
+ query_src = ts_queries.scala_highlights;
+ } else if (extIs(ext, .{ ".zig", ".zon" })) {
lang = tree_sitter_zig();
query_src = ts_queries.zig_highlights;
} else return &.{};
@@ -2173,12 +2347,12 @@ fn insertRunText(t: *Term, text: []const u8) void {
// edit delete d change c yank y paste p undo u redo U
// chord Enter -> acme look (open file/dir) Tab -> acme execute (run it)
// (act on the selection if one is active, else the word under cursor)
-// mode Esc collapses the selection; insert Esc -> normal; Ctrl-b -> raw tty
+// mode Esc collapses the selection; insert Esc -> normal; configured Ctrl key -> raw tty
// window Ctrl-w h/j/k/l focus neighbor pane (Alt-n new term, Alt-c new col)
// File panes mutate their content (real edits) with whole-content undo/redo;
// terminals yank rendered text and paste it as an insertion run (shell output can't
// be deleted, so d/c there only clear pasted runs in the selected rows + yank).
-// Enter raw tty mode (the Ctrl-b toggle; terminals only). tty-native: if the shell is at a
+// Enter raw tty mode (the configured Ctrl-key toggle; terminals only). tty-native: if the shell is at a
// prompt and the modal cursor sits on the current input line, move the shell's
// REAL cursor there via ghostty's promptClickMove (sends left/right arrows the
// shell understands) — so dropping into tty lands where you navigated: at the
@@ -2222,7 +2396,7 @@ fn handleNormal(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key
const half: usize = @max(1, t.rows / 2);
// Esc collapses any selection / cancels a pending prefix (helix). It does NOT
- // drop terminals to tty — that's the deliberate Ctrl-b toggle.
+ // drop terminals to tty — that's the deliberate configured Ctrl-key toggle.
if (key.matches(vaxis.Key.escape, .{})) {
t.vsel.active = false;
t.msel.active = false;
@@ -2637,6 +2811,11 @@ fn actOnSelection(
.colors => colors_on = !colors_on,
.nextcolor => theme_idx = (theme_idx + 1) % themes.len,
.kill => return true, // close everything: signal the main loop to quit
+ .save => {
+ if (t.file) |*f| saveFile(io, f) catch |err| {
+ std.log.warn("save {s}: {}", .{ f.path, err });
+ };
+ },
.send => {
// terminal: run in itself. file pane: run in a shell sitting in the
// file's directory (found by cwd, else a fresh tty in the right column).
@@ -2737,14 +2916,17 @@ pub fn main(init: std.process.Init) !void {
const io = init.io;
const gpa = init.gpa;
- // --tty: start tmux-style — a single shell pane already in tty mode — instead
- // of the default multi-pane editor layout. It only changes the startup layout
- // and mode; everything else behaves identically.
- const tty_only = blk: {
- const args = init.minimal.args.toSlice(init.arena.allocator()) catch break :blk false;
- for (args) |a| if (std.mem.eql(u8, a, "--tty")) break :blk true;
- break :blk false;
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ const cli = parseCli(args) catch |err| {
+ try printHelp(io);
+ return err;
};
+ if (cli.help) {
+ try printHelp(io);
+ return;
+ }
+ const tty_only = cli.tty_only;
+ const tty_toggle = cli.tty_toggle;
var screen_w: u16 = 80;
var screen_h: u16 = 24;
@@ -3132,16 +3314,15 @@ pub fn main(init: std.process.Init) !void {
break :kpress;
}
- // Ctrl-b: hidden toggle in/out of raw tty mode (terminals only).
+ // Configured Ctrl key: hidden toggle in/out of raw tty mode
+ // (terminals only).
// tty forwards every key straight to the pty, so it's deliberately
// off the normal editing path — this toggle is the only way in/out.
- if (key.matches('b', .{ .ctrl = true })) {
- if (at.isTerminal()) {
- if (at.mode == .tty) {
- at.mode = .normal;
- at.pending = 0;
- } else enterTty(at);
- }
+ if (at.isTerminal() and key.matches(tty_toggle, .{ .ctrl = true })) {
+ if (at.mode == .tty) {
+ at.mode = .normal;
+ at.pending = 0;
+ } else enterTty(at);
break :kpress;
}
@@ -3191,7 +3372,8 @@ pub fn main(init: std.process.Init) !void {
}
// per-mode dispatch. Esc: insert -> normal (normal Esc is a
- // no-op, like helix). Ctrl-b toggles raw tty on terminals (above).
+ // no-op, like helix). The configured Ctrl key toggles raw tty
+ // on terminals (above).
// tty forwards every key to the pty.
switch (at.mode) {
.normal => handleNormal(gpa, frame_arena.allocator(), at, key),
@@ -3562,7 +3744,7 @@ fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 {
const body = try t.term.plainString(alloc);
defer alloc.free(body);
// text mode (normal AND insert) hides shell prompt rows: a clean acme-style
- // page to navigate and type on. Only raw tty mode (the Ctrl-b toggle) shows
+ // page to navigate and type on. Only raw tty mode (the configured Ctrl-key toggle) shows
// the real terminal with its prompts + typed input.
var flagbuf: [512]bool = undefined;
const nrows: usize = @min(t.rows, flagbuf.len);
diff --git a/term.zig b/term.zig
index 27dbfef2..5e472a9e 100644
--- a/term.zig
+++ b/term.zig
@@ -15,10 +15,10 @@ pub const Sel = struct {
/// Modal input mode. normal/insert are the helix-style text-editing modes; tty
/// forwards keys to the shell (terminal panes only — file panes never use it).
/// normal -> helix NORMAL: hjkl/w/b/e/... move a block cursor; i/a/o -> insert;
-/// v/x select; Esc collapses the selection; Ctrl-b -> tty (terminals).
+/// v/x select; Esc collapses the selection; configured Ctrl key -> tty (terminals).
/// insert -> keys insert text at the cursor (acme click-and-type); Esc -> normal.
/// tty -> keys -> pty as terminal sequences. The mouse stays usable here (it
-/// selects/copies without dropping you back to normal); Ctrl-b exits.
+/// selects/copies without dropping you back to normal); the configured Ctrl key exits.
pub const Mode = enum { normal, insert, tty };
/// A modal line selection (helix `x` extends the selection down line by line).
diff --git a/tests.zig b/tests.zig
index e0d982bd..1ada869d 100644
--- a/tests.zig
+++ b/tests.zig
@@ -38,6 +38,7 @@ fn colOf(text: []const u8, needle: []const u8) ?usize {
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
+ const io = init.io;
const args = try init.minimal.args.toSlice(init.arena.allocator());
if (args.len != 2) {
std.debug.print("usage: {s} <pardes-exe>\n", .{args[0]});
@@ -299,6 +300,8 @@ pub fn main(init: std.process.Init) !void {
try hs.pump(800); // build the run
try hs.send("\x1b[<2;70;19M\x1b[<2;70;19m"); // right-click on the typed path (+1 row: global tagbar)
try hs.expectWaitFor("ZZDEEPMARK", 12000, "right-click 'path:150' did not open the file scrolled to line 150");
+ try hs.expectContains("Save Del", "file pane tag did not include the default Save command");
+ try hs.expectNotContains("Delcol", "Delcol is still shown in the default pane tag");
// 9. Modal modes on the active FILE pane (left open & focused by step 8):
// `i` -> INSERT (tag shows "in"), Esc -> NORMAL ("nm"). Validates the
@@ -359,6 +362,25 @@ pub fn main(init: std.process.Init) !void {
try hs.pump(800);
try hs.expectNotContains("ZZVSELMARK", "U (redo) did not re-apply the delete");
+ // 10c. Save writes the current file pane back to disk. Add a durable marker,
+ // middle-click the default "Save" command in the file tag, then read the
+ // real file from /tmp and confirm the edit landed there.
+ try hs.send("o"); // open a new line below -> insert
+ try hs.pump(500);
+ try hs.send("ZZSAVEDISK");
+ try hs.pump(500);
+ try hs.send("\x1b"); // -> normal
+ try hs.pump(500);
+ try hs.expectContains("ZZSAVEDISK", "save setup: marker not visible in the file pane");
+ try hs.send("\x1b[<1;31;2M\x1b[<1;31;2m"); // middle-click "Save" in the file tag
+ try hs.pump(1000);
+ const saved = try std.Io.Dir.cwd().readFileAlloc(io, "/tmp/pardesE2Efile.txt", gpa, .limited(1 << 20));
+ defer gpa.free(saved);
+ if (std.mem.indexOf(u8, saved, "ZZSAVEDISK") == null) {
+ hs.dump("Save command did not write the edited file content to disk");
+ return error.SaveDidNotWriteDisk;
+ }
+
// 11. The Tutor builtin now lives in the GLOBAL tagbar (top row, full width):
// "Kill Newcol Tutor Debug". Clicking "Tutor" there spawns the tutor as a
// file pane. The tutor is embedded in the binary (@embedFile), so its
@@ -382,6 +404,34 @@ pub fn main(init: std.process.Init) !void {
try hs2.expectWaitFor("ZZTTYFLAG", 8000, "--tty single shell did not forward input to the shell");
}
+ // 12b. -h/--help print usage and exit before entering the TUI.
+ {
+ var hsh = try Harness.initArgs(gpa, exe.ptr, 12, 80, "-h");
+ defer hsh.deinit();
+ try hsh.expectWaitFor("Usage: pardes", 4000, "-h did not print usage");
+ try hsh.expectContains("--tty-toggle", "-h help did not mention --tty-toggle");
+ }
+ {
+ var hsh2 = try Harness.initArgs(gpa, exe.ptr, 12, 80, "--help");
+ defer hsh2.deinit();
+ try hsh2.expectWaitFor("Usage: pardes", 4000, "--help did not print usage");
+ }
+
+ // 12c. The tty toggle can be moved off Ctrl-b. With --tty-toggle=g, Ctrl-b
+ // should no longer enter tty; Ctrl-g should.
+ {
+ var hsg = try Harness.initArgs(gpa, exe.ptr, 24, 80, "--tty-toggle=g");
+ defer hsg.deinit();
+ try hsg.expectWaitFor("nm /", 6000, "--tty-toggle=g app did not start in normal mode");
+ try hsg.send("\x02"); // Ctrl-b: now just a normal-mode key, not the tty toggle
+ try hsg.pump(600);
+ try hsg.expectNotContains("sy /", "Ctrl-b still toggled tty after --tty-toggle=g");
+ try hsg.send("\x07"); // Ctrl-g: configured tty toggle
+ try hsg.expectWaitFor("sy /", 6000, "Ctrl-g did not toggle tty after --tty-toggle=g");
+ try hsg.send("\x07");
+ try hsg.expectWaitFor("nm /", 6000, "Ctrl-g did not toggle tty back to normal");
+ }
+
// 13. Terminal char-mode editing end to end (fresh instance for a clean pane):
// insert an overlay run, backspace part of it, Ctrl-b to tty (overlay hidden)
// and back (overlay returns), then u (undo the whole insert) / U (redo).
diff --git a/tutor.txt b/tutor.txt
index 3ce27edb..5a93d388 100644
--- a/tutor.txt
+++ b/tutor.txt
@@ -25,7 +25,8 @@
This tutor is in THREE parts, ordered by what's most different from
editors you may know:
PART 1 — the MOUSE. Acme's three buttons; nothing like Vim/Helix.
- PART 2 — the TTY. A terminal is just a pane; Ctrl-b drops into it.
+ PART 2 — the TTY. A terminal is just a pane; Ctrl-b drops into it
+ by default.
PART 3 — the KEYS. Helix-style modal (with the Pardes differences).
PRACTICE BLOCKS (part 3): the "# keys:" line lists keystrokes
@@ -71,9 +72,11 @@
Tab to execute or Enter to look — the acme chords on the keyboard.
THE TAG: each pane has a one-line tag: its MODE (nm/in/sy) + directory
- or file path + the builtins "Del Delcol". Middle-click "Del" to close
- the window, "Delcol" to close its column. A SEPARATE bar across the top
- of the screen holds the window-agnostic builtins:
+ or file path + builtins. File panes show "Save Del" by default; Save
+ writes the current file to disk, Del closes the window. Terminal and
+ image panes show "Del". "Delcol" still exists as a command: type it in
+ a tag or body and execute it to close the whole column. A SEPARATE bar
+ across the top of the screen holds the window-agnostic builtins:
Kill Newcol Tutor Debug Colors NextColor
Middle-click "Newcol" for a new column, "Tutor" to spawn this tutor,
"Kill" to quit; Debug/Colors/NextColor toggle the stats overlay, the
@@ -115,10 +118,11 @@
go straight to the pty as terminal input.
ESC: insert -> normal. (Esc never reaches tty.)
- Ctrl-b: toggles TTY on a terminal — the ONLY way in or out. Entering
- is tty-native: if the shell is at a prompt and your modal
- cursor sits on the input line, it first moves the shell's REAL
- cursor to that spot —
+ Ctrl-b: toggles TTY on a terminal by default — the ONLY way in or out.
+ Use `pardes --tty-toggle=g` (or another letter) to make Ctrl-g
+ the toggle instead. Entering is tty-native: if the shell is at
+ a prompt and your modal cursor sits on the input line, it first
+ moves the shell's REAL cursor to that spot —
- empty input -> shell cursor at the prompt START
- typed text -> shell cursor at/after the char you were on
via the shell's OSC 133 semantic prompt (ghostty promptClickMove;
@@ -129,9 +133,9 @@
chords on the keyboard: Enter = "look" (open the path under the cursor),
Tab = "execute". Both act on the selection if one is active.
- So the flow is: navigate the hidden-prompt page in normal, hit Ctrl-b
- where you want to keep typing, and you're IN the live shell at that
- spot. No separate "terminal mode cursor" to reconcile.
+ So the flow is: navigate the hidden-prompt page in normal, hit the tty
+ toggle where you want to keep typing, and you're IN the live shell at
+ that spot. No separate "terminal mode cursor" to reconcile.
(The cursor positioning is enterTty's promptClickMove; it needs a live
shell at a prompt. Mouse/tty behavior is exercised by the e2e suite.)
@@ -150,7 +154,8 @@
- NO verb+noun (Vim's dw, cw). Motions only MOVE. To delete a word,
select it (`v` then motions, or `x` for whole lines) then `d`.
- ESC never reaches tty; it only does insert -> normal. Dropping a
- terminal into the live shell is Ctrl-b (see Part 2).
+ terminal into the live shell is the tty toggle (Ctrl-b by default;
+ see Part 2).
- Enter / Tab in normal mirror the mouse: Enter = look (open the path
under the cursor), Tab = execute. `u` undo, `U` redo.
- Editing a file pane MUTATES real content; a terminal pane yanks
@@ -302,8 +307,9 @@
= 3.5 LINE SELECTION + EDIT: x d c y p =
-----------------------------------------------------------------
- The Pardes difference. No `v` select-mode: `x` grows a line selection
- downward; d/c/y act on it.
+ The Pardes difference is LINE-first selection: `x` grows a line
+ selection downward; d/c/y act on it. `v` is still available for a
+ character range when you need one.
`x` start/extend a line selection
`d` delete the selected lines (yanked)
@@ -367,8 +373,9 @@
zt / zz / zb scroll so the cursor is at top/center/bottom
Ctrl-d / Ctrl-u half-page down / up
- Ctrl-f full page down. (Ctrl-b pages up on a file pane,
- but on a terminal it's the tty toggle — see Part 2.)
+ Ctrl-f full page down. (Ctrl-b pages up on a file pane;
+ on a terminal it is the default tty toggle. If you
+ start with --tty-toggle=g, Ctrl-g takes that role.)
Ctrl-w h/j/k/l focus the pane left/down/up/right
Alt-n new terminal below the active one
Alt-c move the active terminal into a fresh column
@@ -382,9 +389,9 @@
MOUSE (acme): L select/focus+pin M execute R look(open)
select-then-act: middle-drag, or v/x then Tab
- tag = mode + dir + "Del Delcol"; top bar = Kill Newcol
- Tutor Debug Colors NextColor
- TTY: a terminal IS a pane; Ctrl-b toggles the live shell,
+ file tag = mode + path + "Save Del"; other tags show Del
+ top bar = Kill Newcol Tutor Debug Colors NextColor
+ TTY: a terminal IS a pane; Ctrl-b toggles the live shell by default,
landing its cursor where you navigated (prompt start if
the input is empty, mid-text otherwise)
KEYS (helix): h j k l w b e 0 $ ^ gg G v x d c y p i a I A o O
@@ -394,8 +401,8 @@
vs Helix: no multi-cursor; selection is LINE-first (x), plus v chars.
vs Vim: no verb+noun (dw); motions only move; Esc never reaches tty.
vs both: a terminal is just a pane; the same keys edit text and
- navigate the shell screen, and Ctrl-b reuses the shell's
- own prompt to position you precisely.
+ navigate the shell screen, and the tty toggle reuses the
+ shell's own prompt to position you precisely.
To spawn THIS tutor again from anywhere: middle-click "Tutor" in the
top bar (next to Kill / Newcol).