diff --git a/README.md b/README.md index a6e1335..a4b15f3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Instead of piping opaque text between programs, gleshell pipelines pass typed va ```text ~/code/gleshell on  main -❯ ls | where type == file | select name size | first 5 + ❯ ls | where type == file | select name size | first 5 ╭──────┬──────╮ │ name │ size │ ├──────┼──────┤ @@ -15,8 +15,11 @@ Instead of piping opaque text between programs, gleshell pipelines pass typed va ``` The interactive prompt is zero-config and Starship-inspired: full path with `~`, -optional git branch (read from `.git`, no external tools), and a `❯` character -that turns red after a non-zero exit. +optional git branch (read from `.git`, no external tools), and a Nerd Font shell +icon (``) before the `❯` character (which turns red after a non-zero exit). +Install a Nerd Font so the glyphs render (flake: `nix profile install +.#nerd-fonts`, or put `nerd-fonts.symbols-only` in your system/fonts packages; +the devenv shell already includes it). ## Quick start @@ -31,13 +34,30 @@ gle -c 'ls | first 3' nix run . -- -c 'ls | first 3' # dev shell (source tree; gleam, erlang, rebar3) -# --no-pure-eval lets devenv use the real project dir (not /nix/store) +# Preferred: direnv auto-loads the flake shell (see .envrc). Once: +# direnv allow +# then cd into the repo (nushell needs a direnv pre_prompt hook; see below). +# Manual alternative — bare `nix develop` fails under pure flake eval: nix develop --no-pure-eval gleam run # interactive REPL gleam run -- -c 'ls | first 3' gleam test ``` +### direnv + +This repo ships a [`.envrc`](.envrc) that runs `use flake . --no-pure-eval` +so devenv can see the real project directory (pure flake eval cannot). + +1. Install [direnv](https://direnv.net/) and hook it into your shell + ([nushell](https://github.com/direnv/direnv/wiki/Nushell), bash, zsh, …). +2. From the repo root: `direnv allow` +3. Open a new shell (or `cd .`) — you should see the devenv enter message and + have `gleam` on `PATH` without running `nix develop`. + +Bare `nix develop` still needs `--no-pure-eval` for the same reason; with +direnv you usually do not need `nix develop` at all. + ### REPL editing On a TTY the interactive REPL uses a **raw-mode line editor** with @@ -116,7 +136,11 @@ Filesystem: `ls`, `cd`, `pwd`, `cat`, `open`, `save` Table/list: `where`/`filter`, `find`, `select`, `get`, `first`, `last`, `take`, `skip`, `sort-by`, `reverse`, `length`, `columns`, `table`, `flatten`, `uniq`, `wrap`, `unwrap`, `keys`, `values` -Data: `echo`, `range`, `lines`, `to`/`from` (subcommand `json`), `type`, `describe`, `env`, `sys`, `which`, `help`, `exit` +Data: `echo`, `range`, `lines`, `to`/`from` (subcommand `json`), `type`, `describe`, `env`, `sys`, `which`, `help`, `about`, `exit` + +Pager: `less` — builtin color-aware pager for pipeline input or files (`ls | less`, +`less README.md`). ANSI from tables and external tools is kept; short output is +printed without an interactive session. `^less` still runs the external binary. Unknown command names fall through to external executables on `PATH`. @@ -131,6 +155,7 @@ src/ lexer.gleam / parser.gleam eval.gleam # pipeline evaluator builtins.gleam # Nu-inspired commands + pager.gleam # color-aware builtin less display.gleam # table pretty-printer color.gleam # Nushell-style ANSI colors highlight.gleam # live input syntax highlighting @@ -140,11 +165,15 @@ src/ Input and output are colorized on a TTY (Nu-like shapes on the command line; headers bold green, numbers purple, bools cyan, dirs blue, errors red, …). External tools (`jj`, `git`, `fastfetch`, …) keep their own colors: final-stage -commands inherit the real TTY (or get `FORCE_COLOR` when output is captured), and -pre-colored text is not re-painted by the shell. While the raw line editor is -active, the TTY is switched to cooked mode for those children so LF-only -output does not staircase. Paginated output gets `LESS=FRX` when unset so -`less` passes ANSI through. +commands inherit the real TTY; captured pipeline stages get `FORCE_COLOR` / +`CLICOLOR_FORCE`, and git gets `color.ui=always` plus `log.decorate=short` via +`GIT_CONFIG_*` (git ignores `FORCE_COLOR`, and `decorate=auto` drops +`(HEAD, origin/main, …)` when stdout is a pipe — so bare `git log | less` +would otherwise lose both color and ref decorations). Pre-colored text is not +re-painted by the shell. While the raw line editor is active, the TTY is +switched to cooked mode for those children so LF-only output does not +staircase. The builtin `less` keeps ANSI (like `less -R`); external pagers +still get `LESS=FRX` when unset so they pass colors through. Disable with `NO_COLOR=1`; force with `FORCE_COLOR=1`. ## Status diff --git a/devenv.nix b/devenv.nix index bb261f4..b1c6f8b 100644 --- a/devenv.nix +++ b/devenv.nix @@ -5,6 +5,8 @@ gleam beamPackages.erlang rebar3 + # Icons for the REPL prompt (``, ``, …). Symbols-only is small. + nerd-fonts.symbols-only ]; enterShell = '' diff --git a/flake.nix b/flake.nix index ac4c472..df353cc 100644 --- a/flake.nix +++ b/flake.nix @@ -136,10 +136,15 @@ license = lib.licenses.asl20; }; }; + + # Prompt icons (``, ``, …). Install so the REPL glyph set renders: + # nix profile install .#nerd-fonts + # On NixOS/home-manager prefer fonts.packages instead. + nerd-fonts = pkgs.nerd-fonts.symbols-only; in { default = gleshell; - inherit gleshell; + inherit gleshell nerd-fonts; } ); diff --git a/src/gleshell.gleam b/src/gleshell.gleam index 0390da3..43a7cdc 100644 --- a/src/gleshell.gleam +++ b/src/gleshell.gleam @@ -109,10 +109,14 @@ fn repl_loop(env: env.Env) -> Nil { } /// Zero-config prompt inspired by Starship: -/// blank line, directory (+ optional git branch), then a green/red `❯`. +/// blank line, directory (+ optional git branch), then Nerd Font shell icon +/// + green/red `❯`. /// /// Status lines are printed once; only the character is the line-editor prompt /// (the raw editor redraws a single line). +/// +/// Icon `` is nf-oct-terminal (Nerd Fonts). Install `nerd-fonts.symbols-only` +/// (or any Nerd Font) so it renders — see the flake packages / devenv. fn prompt_for(env: env.Env) -> String { let on = color.enabled() let path = color.prompt_path(on, display_cwd(env.cwd)) @@ -129,11 +133,13 @@ fn prompt_for(env: env.Env) -> String { fn prompt_character(last_exit: Int) -> String { let on = color.enabled() + // Cute Nerd Font marker so you always know you're in gleshell. + let icon = color.prompt_name(on, "") let mark = case last_exit { 0 -> color.prompt_character_ok(on, "❯") _ -> color.prompt_character_err(on, "❯") } - mark <> " " + icon <> " " <> mark <> " " } /// Full cwd for the prompt, with `$HOME` shown as `~`. diff --git a/src/gleshell/builtins.gleam b/src/gleshell/builtins.gleam index 4b603ae..efef522 100644 --- a/src/gleshell/builtins.gleam +++ b/src/gleshell/builtins.gleam @@ -10,7 +10,9 @@ import gleam/list import gleam/option import gleam/order import gleam/string +import gleshell/display import gleshell/env.{type Env} +import gleshell/pager import gleshell/sys import gleshell/value.{ type Value, Bool, Fail, Float, Int, List, Nothing, Record, String, Table, @@ -77,6 +79,8 @@ pub fn registry() -> dict.Dict(String, Builtin) { #("values", cmd_values), #("keys", cmd_keys), #("sys", cmd_sys), + #("about", cmd_about), + #("less", cmd_less), ]) } @@ -214,6 +218,30 @@ fn help_for(name: String) -> Result(String, Nil) { ], "\n", )) + "less" -> + Ok(string.join( + [ + "less [file]… — page pipeline input or files (ANSI colors kept)", + "", + "Builtin pager inspired by less -FRX: colors from tools and gleshell", + "tables pass through; if the text fits on one screen (or stdout is not", + "a TTY), it is printed and the pager exits. Use `^less` for the", + "external binary on PATH.", + "", + "Keys (interactive):", + " j / ↓ / Enter line down k / ↑ line up", + " space / f / PgDn page down b / PgUp page up", + " g / Home top G / End bottom", + " h / ? help q / Ctrl+C quit", + "", + "Examples:", + " ls | less", + " cat README.md | less", + " less README.md", + " ^jj log | less", + ], + "\n", + )) _ -> help_line(name) } } @@ -293,6 +321,11 @@ fn help_text() -> dict.Dict(String, String) { #("values", "values — list of values from a record"), #("keys", "keys — list of keys from a record"), #("sys", "sys — host info record (cwd, home, shell, last_exit)"), + #("about", "about — authorship, ATProto handle, and a little sparkle"), + #( + "less", + "less [file]… — page pipeline input or files (ANSI colors kept)", + ), ]) } @@ -1720,6 +1753,107 @@ fn cmd_sys( ) } +// --- less (color-aware pager) --- + +fn cmd_less( + env: Env, + input: Value, + args: List(Value), + _flags: dict.Dict(String, Value), +) -> BuiltinResult { + case less_content(env, input, args) { + Error(msg) -> err(env, msg) + Ok(text) -> + case pager.needs_paging(text) { + True -> { + pager.run(text) + ok(env, Nothing) + } + // Fits on one screen or not a TTY: emit the text so the REPL / -c + // path prints it once (ANSI preserved by display.render for strings). + False -> ok(env, String(text)) + } + } +} + +/// Build the text to page: file args win (like external less); otherwise +/// pipeline input. Raw strings (e.g. `git log` output) are kept byte-for-byte +/// so embedded ANSI is not re-painted; structured values are pretty-printed +/// with colors via `display.render`. +fn less_content( + env: Env, + input: Value, + args: List(Value), +) -> Result(String, String) { + case args { + [] -> + case input { + Nothing -> Error("less: no input (pipe data or pass a file path)") + // Do not run display.render on external text: short plain lines would + // get string-green, and multi-line ANSI must stay exactly as emitted. + String(s) -> Ok(s) + other -> Ok(display.render(other)) + } + paths -> { + case list.try_map(paths, fn(a) { + case a { + String(path) -> read_less_file(env, path) + _ -> Error("less: expected file path") + } + }) { + Error(e) -> Error(e) + Ok(parts) -> Ok(string.join(parts, "\n")) + } + } + } +} + +fn read_less_file(env: Env, path: String) -> Result(String, String) { + let path = resolve_path(env, path) + case simplifile.read(path) { + Ok(content) -> Ok(content) + Error(e) -> Error("less: " <> simplifile.describe_error(e)) + } +} + +// --- about --- + +fn cmd_about( + env: Env, + _input: Value, + _args: List(Value), + _flags: dict.Dict(String, Value), +) -> BuiltinResult { + ok(env, String(about_text())) +} + +fn about_text() -> String { + string.join( + [ + " ✨ gleshell ✨", + " a structured-data shell in Gleam", + " inspired by Nushell · pipelines with types", + "", + " ╱|、", + " (˚ˎ 。7", + " |、˜〵", + " じしˍ,)ノ meow · you found the about page", + "", + " author NaNdi", + " handle @nandi.uk", + " atproto did:plc:ngokl2gnmpbvuvrfckja3g7p", + " web https://latha.org", + " licence Apache-2.0", + "", + " \"a category is a quiver under the free functor\"", + "", + " 🐚 type `help` to explore · `^cmd` for externals", + " 💜 made for people who pipe records, not just text", + ], + "\n", + ) +} + // --- helpers --- fn list_at(items: List(a), index: Int) -> Result(a, Nil) { diff --git a/src/gleshell/lexer.gleam b/src/gleshell/lexer.gleam index b2dd657..a2b644c 100644 --- a/src/gleshell/lexer.gleam +++ b/src/gleshell/lexer.gleam @@ -88,7 +88,9 @@ fn do_tokenize( ["-", "-", ..rest] -> { let #(name, after, new_pos) = read_ident_body(rest, pos + 2, "") case name { - "" -> Error(LexError("expected flag name after --", pos)) + // Bare `--` is the POSIX end-of-options marker (`nix run . -- args`). + // Empty flag name is parsed as a literal `"--"` argument. + "" -> do_tokenize(after, new_pos, [Flag(""), ..acc]) n -> do_tokenize(after, new_pos, [Flag(n), ..acc]) } } diff --git a/src/gleshell/pager.gleam b/src/gleshell/pager.gleam new file mode 100644 index 0000000..5aeed88 --- /dev/null +++ b/src/gleshell/pager.gleam @@ -0,0 +1,309 @@ +//// Color-aware pager (builtin `less`). +//// +//// Passes ANSI through unchanged, sizes pages with `color.visible_length`, +//// and uses the alternate screen while interactive so the REPL is restored +//// on quit. When stdout is not a TTY, or the text fits on one screen, the +//// caller should print the text itself (see `needs_paging`). + +import gleam/int +import gleam/list +import gleam/string +import gleshell/color +import gleshell/sys + +/// True when stdout is a TTY and wrapping `text` to the terminal width yields +/// more lines than fit on one screen (minus the status row). +pub fn needs_paging(text: String) -> Bool { + case sys.term_size() { + Error(Nil) -> False + Ok(#(rows, cols)) -> { + let height = page_height(rows) + let lines = display_lines(text, cols) + list.length(lines) > height + } + } +} + +/// Interactive page session. Call only when `needs_paging` is True. +/// Leaves the alternate screen on exit; does not print the text afterwards. +pub fn run(text: String) -> Nil { + case sys.term_size() { + Error(Nil) -> Nil + Ok(#(rows, cols)) -> { + let height = page_height(rows) + let lines = display_lines(text, cols) + let total = list.length(lines) + sys.with_key_mode(fn() { + enter_alt_screen() + page_loop(lines, total, 0, height, cols) + leave_alt_screen() + }) + } + } +} + +fn page_height(rows: Int) -> Int { + int.max(1, rows - 1) +} + +/// Split on newlines, then soft-wrap each logical line to `cols` using +/// ANSI-aware visible width so color codes do not throw off wrapping. +pub fn display_lines(text: String, cols: Int) -> List(String) { + let cols = int.max(1, cols) + text + |> string.replace("\r\n", "\n") + |> string.replace("\r", "\n") + |> string.split("\n") + |> list.flat_map(fn(line) { wrap_line(line, cols) }) +} + +fn page_loop( + lines: List(String), + total: Int, + offset: Int, + height: Int, + cols: Int, +) -> Nil { + let max_off = int.max(0, total - height) + let offset = int.clamp(offset, 0, max_off) + redraw(lines, total, offset, height, cols) + case sys.read_key_name() { + Error(_) -> Nil + Ok("eof") -> Nil + Ok("q") | Ok("Q") | Ok("ctrl_c") | Ok("ctrl_d") -> Nil + Ok("down") | Ok("j") | Ok("enter") -> + page_loop(lines, total, offset + 1, height, cols) + Ok("up") | Ok("k") -> page_loop(lines, total, offset - 1, height, cols) + Ok("space") | Ok("f") | Ok("page_down") | Ok("ctrl_f") -> + page_loop(lines, total, offset + height, height, cols) + Ok("b") | Ok("page_up") | Ok("ctrl_b") -> + page_loop(lines, total, offset - height, height, cols) + Ok("g") | Ok("home") -> page_loop(lines, total, 0, height, cols) + Ok("G") | Ok("end") -> page_loop(lines, total, max_off, height, cols) + Ok("ctrl_l") -> page_loop(lines, total, offset, height, cols) + Ok("h") | Ok("?") -> { + show_help(height, cols) + page_loop(lines, total, offset, height, cols) + } + Ok(_) -> page_loop(lines, total, offset, height, cols) + } +} + +fn redraw( + lines: List(String), + total: Int, + offset: Int, + height: Int, + cols: Int, +) -> Nil { + // Home + clear + SGR reset inside the alternate buffer. + // Do not reset SGR after every row: soft-wrapped chunks only open CSI on the + // first physical line, so color must continue until the content resets it. + sys.write("\u{001b}[H\u{001b}[2J\u{001b}[0m") + let view = list_slice(lines, offset, height) + let padded = pad_to(view, height) + list.each(padded, fn(line) { + sys.write(line <> "\u{001b}[K\r\n") + }) + // Ensure the status bar is not tinted by leftover content SGR. + sys.write("\u{001b}[0m" <> status_line(offset, height, total, cols)) +} + +fn status_line(offset: Int, height: Int, total: Int, cols: Int) -> String { + let at_end = offset + height >= total + let label = case total { + 0 -> " (empty) " + _ if at_end -> " (END) " + _ -> { + let bottom = int.min(total, offset + height) + let pct = case total { + 0 -> 100 + n -> bottom * 100 / n + } + " " + <> int.to_string(offset + 1) + <> "-" + <> int.to_string(bottom) + <> "/" + <> int.to_string(total) + <> " (" + <> int.to_string(pct) + <> "%) " + } + } + let help = " q:quit j/k:line space/b:page g/G:top/end h:help " + let plain = label <> help + let plain = case color.visible_length(plain) > cols { + True -> label + False -> plain + } + let on = color.enabled() + let body = case on { + True -> "\u{001b}[7m" <> pad_status(plain, cols) <> "\u{001b}[0m" + False -> pad_status(plain, cols) + } + // Stay on the status row (no trailing newline). + body <> "\u{001b}[K" +} + +fn pad_status(text: String, cols: Int) -> String { + let vis = color.visible_length(text) + case vis >= cols { + True -> text + False -> text <> string.repeat(" ", cols - vis) + } +} + +fn show_help(height: Int, cols: Int) -> Nil { + let help_text = + string.join( + [ + "gleshell less — color-aware pager", + "", + " j / ↓ / Enter one line down", + " k / ↑ one line up", + " space / f / PgDn one page down", + " b / PgUp one page up", + " g / Home top", + " G / End bottom", + " Ctrl+L redraw", + " h / ? this help", + " q / Q / Ctrl+C quit", + "", + "ANSI colors from tools and tables are kept (like less -R).", + "", + "Press any key to return…", + ], + "\n", + ) + let lines = display_lines(help_text, cols) + redraw(lines, list.length(lines), 0, height, cols) + let _ = sys.read_key_name() + Nil +} + +fn enter_alt_screen() -> Nil { + // Save cursor + enter alternate screen buffer (xterm/most terminals). + sys.write("\u{001b}[?1049h\u{001b}[H") +} + +fn leave_alt_screen() -> Nil { + sys.write("\u{001b}[?1049l") +} + +fn list_slice(items: List(String), offset: Int, n: Int) -> List(String) { + items + |> list.drop(offset) + |> list.take(n) +} + +fn pad_to(lines: List(String), height: Int) -> List(String) { + let missing = height - list.length(lines) + case missing > 0 { + True -> list.append(lines, list.repeat("", missing)) + False -> lines + } +} + +/// Soft-wrap one logical line to at most `cols` visible columns. +/// Escape sequences never count toward width and are never split mid-sequence. +pub fn wrap_line(line: String, cols: Int) -> List(String) { + let cols = int.max(1, cols) + case color.visible_length(line) <= cols { + True -> [line] + False -> wrap_loop(string.to_utf_codepoints(line), cols, 0, "", []) + } +} + +fn wrap_loop( + codes: List(UtfCodepoint), + cols: Int, + vis: Int, + acc_text: String, + out: List(String), +) -> List(String) { + case codes { + [] -> + case acc_text { + "" if out != [] -> list.reverse(out) + "" -> [""] + t -> list.reverse([t, ..out]) + } + [c, ..rest] -> { + let n = string.utf_codepoint_to_int(c) + case n == 0x1B { + True -> { + // Pull the whole CSI / ESC sequence into the current chunk. + let #(seq, after) = take_ansi([c, ..rest]) + wrap_loop( + after, + cols, + vis, + acc_text <> codepoints_to_string(seq), + out, + ) + } + False -> + case vis >= cols { + True -> + // Start a new physical line with this visible character. + wrap_loop(codes, cols, 0, "", [acc_text, ..out]) + False -> + wrap_loop( + rest, + cols, + vis + 1, + acc_text <> codepoints_to_string([c]), + out, + ) + } + } + } + } +} + +fn take_ansi( + codes: List(UtfCodepoint), +) -> #(List(UtfCodepoint), List(UtfCodepoint)) { + case codes { + [] -> #([], []) + [esc, ..rest] -> + case string.utf_codepoint_to_int(esc) == 0x1B { + False -> #([], codes) + True -> + case rest { + [bracket, ..params] -> + case string.utf_codepoint_to_int(bracket) == 0x5B { + // CSI: ESC [ … final(0x40–0x7E) + True -> take_csi([esc, bracket], params) + // Non-CSI ESC: include ESC + one following byte. + False -> #([esc, bracket], params) + } + [] -> #([esc], []) + } + } + } +} + +fn take_csi( + acc: List(UtfCodepoint), + rest: List(UtfCodepoint), +) -> #(List(UtfCodepoint), List(UtfCodepoint)) { + case rest { + [] -> #(acc, []) + [c, ..more] -> { + let n = string.utf_codepoint_to_int(c) + let acc2 = list.append(acc, [c]) + case n >= 0x40 && n <= 0x7E { + True -> #(acc2, more) + False -> take_csi(acc2, more) + } + } + } +} + +fn codepoints_to_string(codes: List(UtfCodepoint)) -> String { + case string.from_utf_codepoints(codes) { + s -> s + } +} diff --git a/src/gleshell/parser.gleam b/src/gleshell/parser.gleam index 65f9cfd..f34cede 100644 --- a/src/gleshell/parser.gleam +++ b/src/gleshell/parser.gleam @@ -235,6 +235,9 @@ fn parse_args( ) -> Result(#(List(Arg), List(Token)), ParseError) { case tokens { [] | [Eof] | [Pipe, ..] -> Ok(#(list.reverse(acc), tokens)) + // Bare `--` (lexer Flag("")) → literal argv element, not a named flag. + [Flag(""), ..rest] -> + parse_args(rest, [ValueArg(Lit(value.String("--"))), ..acc]) [Flag(name), ..rest] -> { case is_expr_start(rest) { True -> { diff --git a/src/gleshell/sys.gleam b/src/gleshell/sys.gleam index b7d455f..6cecfa5 100644 --- a/src/gleshell/sys.gleam +++ b/src/gleshell/sys.gleam @@ -8,6 +8,24 @@ pub fn get_line(prompt: String) -> Result(String, String) @external(erlang, "gleshell_ffi", "println") pub fn println(text: String) -> Nil +/// Write text with no automatic trailing newline. ANSI is passed through. +/// In raw TTY REPL mode, bare LFs become CRLF (same staircase fix as `println`). +@external(erlang, "gleshell_ffi", "write") +pub fn write(text: String) -> Nil + +/// Terminal `{rows, cols}` when stdout is a TTY; Error otherwise. +@external(erlang, "gleshell_ffi", "term_size") +pub fn term_size() -> Result(#(Int, Int), Nil) + +/// One keypress for the builtin pager. See `gleshell_ffi:read_key_name/0`. +/// Prefer calling inside `with_key_mode` when not in the raw REPL. +@external(erlang, "gleshell_ffi", "read_key_name") +pub fn read_key_name() -> Result(String, String) + +/// Run `body` with the TTY in single-key mode when needed (non-raw REPL). +@external(erlang, "gleshell_ffi", "with_key_mode") +pub fn with_key_mode(body: fn() -> Nil) -> Nil + /// Run `body` as the interactive shell (raw TTY editor when possible). /// Ensures Erlang `+Bc` so Ctrl+C cancels the line instead of aborting the VM. @external(erlang, "gleshell_ffi", "run_as_shell") diff --git a/src/gleshell_ffi.erl b/src/gleshell_ffi.erl index ec92c92..06a9975 100644 --- a/src/gleshell_ffi.erl +++ b/src/gleshell_ffi.erl @@ -18,6 +18,10 @@ home_dir/0, stdout_isatty/0, println/1, + write/1, + term_size/0, + read_key_name/0, + with_key_mode/1, take_output_shown/0, clear_output_shown/0, complete_word/2, @@ -44,6 +48,143 @@ println(Text) when is_binary(Text) -> nil end. +%% Write text without an automatic trailing newline. ANSI sequences are passed +%% through unchanged. In raw TTY mode, bare LFs become CRLF so lines do not +%% staircase (same as println/1). +-spec write(binary()) -> nil. +write(Text) when is_binary(Text) -> + case get(gleshell_raw) of + true -> + io:put_chars(to_crlf(Text)), + nil; + _ -> + io:put_chars(Text), + nil + end. + +%% Terminal size when stdout is a TTY. Defaults to 24×80 if the driver omits a +%% dimension. Error when stdout is not a terminal (pager should dump instead). +-spec term_size() -> {ok, {integer(), integer()}} | {error, nil}. +term_size() -> + case stdout_isatty() of + false -> + {error, nil}; + true -> + Rows = + case io:rows() of + {ok, R} when is_integer(R), R > 0 -> + R; + _ -> + 24 + end, + Cols = + case io:columns() of + {ok, C} when is_integer(C), C > 0 -> + C; + _ -> + 80 + end, + {ok, {Rows, Cols}} + end. + +%% Run Fun with the TTY in single-key (non-canonical, no-echo) mode when the +%% REPL is not already raw. Restores termios afterwards. Prefer wrapping a +%% whole pager session rather than each keystroke. +-spec with_key_mode(fun(() -> term())) -> term(). +with_key_mode(Fun) when is_function(Fun, 0) -> + case get(gleshell_raw) of + true -> + Fun(); + _ -> + case stdout_isatty() of + false -> + Fun(); + true -> + case stty_save() of + {ok, Saved} -> + _ = stty_run(["-icanon", "-echo", "min", "1", "time", "0"]), + try + Fun() + after + stty_restore(Saved) + end; + _ -> + Fun() + end + end + end. + +%% Blocking single-key read for the builtin pager. Returns a short name: +%% "up" | "down" | "left" | "right" | "home" | "end" | "page_up" | "page_down" +%% | "enter" | "backspace" | "tab" | "ctrl_c" | "ctrl_l" | "space" | "eof" +%% | single printable grapheme ("q", "j", "G", …) | "other". +%% +%% Call inside `with_key_mode/1` when not already in the raw REPL, so cooked +%% TTYs (e.g. `gleshell -c '… | less'`) still deliver keys one at a time. +-spec read_key_name() -> {ok, binary()} | {error, binary()}. +read_key_name() -> + try + {ok, key_to_name(read_key())} + catch + _:Reason -> + {error, reason_to_bin(Reason)} + end. + +key_to_name(eof) -> + <<"eof">>; +key_to_name(enter) -> + <<"enter">>; +key_to_name(tab) -> + <<"tab">>; +key_to_name(backspace) -> + <<"backspace">>; +key_to_name(delete) -> + <<"delete">>; +key_to_name(up) -> + <<"up">>; +key_to_name(down) -> + <<"down">>; +key_to_name(left) -> + <<"left">>; +key_to_name(right) -> + <<"right">>; +key_to_name(home) -> + <<"home">>; +key_to_name('end') -> + <<"end">>; +key_to_name(page_up) -> + <<"page_up">>; +key_to_name(page_down) -> + <<"page_down">>; +key_to_name(ctrl_c) -> + <<"ctrl_c">>; +key_to_name(ctrl_l) -> + <<"ctrl_l">>; +key_to_name(ctrl_a) -> + <<"ctrl_a">>; +key_to_name(ctrl_e) -> + <<"ctrl_e">>; +key_to_name(ctrl_d) -> + <<"ctrl_d">>; +key_to_name(ctrl_k) -> + <<"ctrl_k">>; +key_to_name(ctrl_u) -> + <<"ctrl_u">>; +key_to_name(ctrl_w) -> + <<"ctrl_w">>; +key_to_name(ctrl_r) -> + <<"ctrl_r">>; +key_to_name(ctrl_g) -> + <<"ctrl_g">>; +key_to_name({char, 32}) -> + <<"space">>; +key_to_name({char, C}) when is_integer(C), C >= 32 -> + unicode:characters_to_binary([C]); +key_to_name({error, _}) -> + <<"eof">>; +key_to_name(_) -> + <<"other">>. + %% Normalize newlines to CRLF without turning existing \r\n into \r\r\n. to_crlf(Bin) when is_binary(Bin) -> B1 = binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]), @@ -555,13 +696,13 @@ to_charlist(L) when is_list(L) -> fallback_builtin_names() -> [ - "append", "cat", "cd", "columns", "count", "describe", "echo", "env", - "exit", "filter", "find", "first", "flatten", "from", "get", "help", - "identity", "ignore", "is-empty", "is_empty", "keys", "last", "length", - "lines", "ls", "open", "prepend", "print", "pwd", "quit", "range", - "reverse", "save", "select", "skip", "sort-by", "sort_by", "sys", - "table", "take", "to", "type", "typeof", "uniq", "unwrap", - "values", "where", "which", "wrap" + "about", "append", "cat", "cd", "columns", "count", "describe", "echo", + "env", "exit", "filter", "find", "first", "flatten", "from", "get", + "help", "identity", "ignore", "is-empty", "is_empty", "keys", "last", + "length", "less", "lines", "ls", "open", "prepend", "print", "pwd", + "quit", "range", "reverse", "save", "select", "skip", "sort-by", + "sort_by", "sys", "table", "take", "to", "type", "typeof", "uniq", + "unwrap", "values", "where", "which", "wrap" ]. %% Executable basenames on PATH that match Prefix (deduped, sorted). @@ -1023,6 +1164,8 @@ read_csi_params(Acc) -> "1" -> home; "3" -> delete; "4" -> 'end'; + "5" -> page_up; + "6" -> page_down; "7" -> home; "8" -> 'end'; _ -> other @@ -2096,9 +2239,12 @@ normalize_pty_output(Bin) when is_binary(Bin) -> %% - SHELL=/bin/sh: `script` invokes $SHELL; nu/fish break `script -c`. %% - LESS=FRX when unset: pagers (jj/git → less) pass through ANSI colors (-R) %% and exit on short output (-F) without clearing the screen (-X). -%% - FORCE_COLOR / CLICOLOR_FORCE when the shell itself wants color and the -%% child has no TTY (direct path): tools like jj/git/ripgrep emit ANSI so -%% we can show their colors when re-printing the captured string. +%% - When the shell wants color and the child has no TTY (pipeline capture): +%% FORCE_COLOR / CLICOLOR_FORCE for tools that honor them (jj, many CLIs), +%% and git GIT_CONFIG_* overlays (git ignores FORCE_COLOR and treats pipes as +%% non-terminals): color.ui=always so `git log | less` is colored, and +%% log.decorate=short because decorate=auto drops ref names +%% (`(HEAD, origin/main, …)`) when stdout is not a TTY. child_env() -> Env0 = [{"SHELL", "/bin/sh"}], Env1 = @@ -2114,23 +2260,47 @@ child_env() -> false -> Env1; true -> - Env2 = - case os:getenv("FORCE_COLOR") of - false -> - [{"FORCE_COLOR", "1"} | Env1]; - "0" -> - Env1; - _ -> - Env1 - end, - case os:getenv("CLICOLOR_FORCE") of - false -> - [{"CLICOLOR_FORCE", "1"} | Env2]; - "0" -> - Env2; - _ -> - Env2 - end + force_git_tty_env(force_color_env(Env1)) + end. + +%% FORCE_COLOR / CLICOLOR_FORCE for children that honor them. +force_color_env(Env) -> + Env1 = + case os:getenv("FORCE_COLOR") of + false -> + [{"FORCE_COLOR", "1"} | Env]; + "0" -> + Env; + _ -> + Env + end, + case os:getenv("CLICOLOR_FORCE") of + false -> + [{"CLICOLOR_FORCE", "1"} | Env1]; + "0" -> + Env1; + _ -> + Env1 + end. + +%% Make git behave like a TTY for captured pipelines (git ≥ 2.31 GIT_CONFIG_*). +%% Skipped when the caller already set GIT_CONFIG_COUNT so we do not clobber. +%% +%% - color.ui=always: git ignores FORCE_COLOR +%% - log.decorate=short: default auto hides decorations on non-TTY stdout +force_git_tty_env(Env) -> + case os:getenv("GIT_CONFIG_COUNT") of + false -> + [ + {"GIT_CONFIG_COUNT", "2"}, + {"GIT_CONFIG_KEY_0", "color.ui"}, + {"GIT_CONFIG_VALUE_0", "always"}, + {"GIT_CONFIG_KEY_1", "log.decorate"}, + {"GIT_CONFIG_VALUE_1", "short"} + | Env + ]; + _ -> + Env end. %% Match gleshell color policy: off under NO_COLOR; otherwise on for a TTY diff --git a/test/gleshell_test.gleam b/test/gleshell_test.gleam index 0ba6fc2..aa89745 100644 --- a/test/gleshell_test.gleam +++ b/test/gleshell_test.gleam @@ -8,6 +8,7 @@ import gleshell/env import gleshell/eval import gleshell/highlight import gleshell/lexer +import gleshell/pager import gleshell/parser import gleshell/sys import gleshell/value.{Bool, Int, List, Nothing, Record, String, Table} @@ -53,6 +54,44 @@ pub fn lexer_path_idents_test() { Nil } +pub fn lexer_bare_double_dash_test() { + // POSIX end-of-options: `nix run . -- args` must not lex-error on bare `--`. + let assert Ok(tokens) = lexer.tokenize("nix run . -- chadfowler.com yolo") + let assert [ + lexer.Ident("nix"), + lexer.Ident("run"), + lexer.Ident("."), + lexer.Flag(""), + lexer.Ident("chadfowler.com"), + lexer.Ident("yolo"), + lexer.Eof, + ] = tokens + // Named long flags still work + let assert Ok(tokens2) = lexer.tokenize("cmd --think --scale 3") + let assert [ + lexer.Ident("cmd"), + lexer.Flag("think"), + lexer.Flag("scale"), + lexer.IntLit(3), + lexer.Eof, + ] = tokens2 + Nil +} + +pub fn parse_bare_double_dash_test() { + let assert Ok(parser.Expr(parser.Pipeline([ + parser.Command("nix", args, False), + ]))) = parser.parse("nix run . -- chadfowler.com yolo") + let assert [ + parser.ValueArg(parser.Lit(String("run"))), + parser.ValueArg(parser.Lit(String("."))), + parser.ValueArg(parser.Lit(String("--"))), + parser.ValueArg(parser.Lit(String("chadfowler.com"))), + parser.ValueArg(parser.Lit(String("yolo"))), + ] = args + Nil +} + pub fn parse_ls_dotfile_test() { let assert Ok(parser.Expr(parser.Pipeline([ parser.Command("ls", args, False), @@ -231,6 +270,20 @@ pub fn eval_where_select_test() { Nil } +pub fn about_command_test() { + let env = env.new() + let assert eval.Continue(_, String(text)) = eval.eval_source(env, "about") + let assert True = string.contains(text, "gleshell") + let assert True = string.contains(text, "nandi.uk") + let assert True = string.contains(text, "NaNdi") + let assert True = string.contains(text, "did:plc:ngokl2gnmpbvuvrfckja3g7p") + let assert True = string.contains(text, "latha.org") + let assert eval.Continue(_, String(which_out)) = + eval.eval_source(env, "which about") + let assert "builtin: about" = which_out + Nil +} + pub fn help_covers_all_builtins_test() { // Every registered builtin must have a dedicated help_text entry. let assert [] = builtins.missing_help() @@ -500,6 +553,129 @@ pub fn color_visible_length_strips_ansi_test() { Nil } +// --- less / pager --- + +pub fn pager_wrap_respects_ansi_width_test() { + // 10 visible chars of content; wrap at 4 → three physical lines. + let painted = color.paint(True, "\u{001b}[32m", "abcdefghij") + let lines = pager.wrap_line(painted, 4) + let assert 3 = list.length(lines) + // Each physical line still carries / continues color; visible width ≤ 4. + list.each(lines, fn(line) { + let assert True = color.visible_length(line) <= 4 + }) + // Joining without separators reconstructs the original SGR + text. + let joined = string.join(lines, "") + let assert True = string.contains(joined, "abcdefghij") + let assert True = string.contains(joined, "\u{001b}[32m") + Nil +} + +pub fn pager_display_lines_splits_newlines_test() { + let lines = pager.display_lines("a\nb\nc", 80) + let assert ["a", "b", "c"] = lines + Nil +} + +pub fn less_short_output_passthrough_test() { + // Non-TTY test runner: needs_paging is false → less returns the text. + let env = env.new() + let assert eval.Continue(_, String(out)) = + eval.eval_source(env, "echo hello | less") + let assert True = string.contains(out, "hello") + let assert eval.Continue(_, String(which_out)) = + eval.eval_source(env, "which less") + let assert "builtin: less" = which_out + Nil +} + +pub fn less_preserves_ansi_in_string_test() { + let env = env.new() + // Pre-colored multi-line text must survive less (no re-paint / strip). + let colored = "\u{001b}[31mred\u{001b}[0m\n\u{001b}[32mgreen\u{001b}[0m" + let assert eval.Continue(_, String(out)) = + eval.eval_source(env, "echo \"" <> escape_for_source(colored) <> "\" | less") + let assert True = string.contains(out, "\u{001b}[31m") + let assert True = string.contains(out, "red") + let assert True = string.contains(out, "\u{001b}[32m") + let assert True = string.contains(out, "green") + Nil +} + +pub fn less_no_input_errors_test() { + let env = env.new() + let assert eval.Continue(env2, value.Fail(msg)) = eval.eval_source(env, "less") + let assert True = string.contains(msg, "no input") + let assert 1 = env2.last_exit + Nil +} + +pub fn less_help_test() { + let env = env.new() + let assert eval.Continue(_, String(help_out)) = + eval.eval_source(env, "help less") + let assert True = string.contains(help_out, "ANSI") + let assert True = string.contains(help_out, "q") + Nil +} + +pub fn pipeline_capture_forces_git_tty_config_test() { + // git ignores FORCE_COLOR and hides decorations on pipes; child_env injects + // color.ui=always + log.decorate=short. FORCE_COLOR makes want_child_color + // true without a TTY. Use `let` so capture mode does not leak printenv. + let prev = sys.getenv("FORCE_COLOR") + let assert Ok(_) = sys.setenv("FORCE_COLOR", "1") + let env = env.new() + let color = eval.eval_source(env, "let x = ^printenv GIT_CONFIG_VALUE_0") + let decorate = eval.eval_source(env, "let y = ^printenv GIT_CONFIG_VALUE_1") + case prev { + Ok(v) -> { + let assert Ok(_) = sys.setenv("FORCE_COLOR", v) + Nil + } + Error(Nil) -> { + let assert Ok(_) = sys.setenv("FORCE_COLOR", "") + Nil + } + } + let assert eval.Continue(_, String(color_out)) = color + let assert eval.Continue(_, String(decorate_out)) = decorate + let assert True = string.contains(color_out, "always") + let assert True = string.contains(decorate_out, "short") + Nil +} + +pub fn git_log_pipeline_emits_ansi_and_decorate_test() { + let prev = sys.getenv("FORCE_COLOR") + let assert Ok(_) = sys.setenv("FORCE_COLOR", "1") + let env = env.new() + // Full log (not --oneline): decorations appear on the commit line. + let result = eval.eval_source(env, "git log -1 | identity") + case prev { + Ok(v) -> { + let assert Ok(_) = sys.setenv("FORCE_COLOR", v) + Nil + } + Error(Nil) -> { + let assert Ok(_) = sys.setenv("FORCE_COLOR", "") + Nil + } + } + let assert eval.Continue(_, String(out)) = result + // Real git colors, not gleshell string-green on plain text. + let assert True = string.contains(out, "\u{001b}[") + // decorate=short: ref names like HEAD / main (auto would omit on a pipe). + let assert True = string.contains(out, "HEAD") || string.contains(out, "main") + Nil +} + +/// Embed a string in double-quoted source: escape `\` and `"`. +fn escape_for_source(s: String) -> String { + s + |> string.replace("\\", "\\\\") + |> string.replace("\"", "\\\"") +} + // --- input syntax highlighting --- pub fn highlight_plain_when_off_test() {