A structured-data shell in Gleam, inspired by Nushell
0

Configure Feed

Select the types of activity you want to include in your feed.

Add find builtin and improve REPL packaging, prompt, and externals.

Introduce a Nushell-style `find` filter (terms, -i/-v, --regex, --columns)
with tests and help. Also ship a self-contained `gle` via flake, Starship-like
prompt with git branch, command/PATH tab completion, and stronger external TTY
color pass-through (Ctrl+C to children, less ANSI, which -a).

author
nandi
date (Jul 25, 2026, 2:07 AM -0700) commit 6e1dd132 parent 36a72df9 change-id orznlmsm
+1939 -255
+2
.gitignore
··· 2 2 *.ez 3 3 /build 4 4 erl_crash.dump 5 + # gleam export escript writes here 6 + /gleshell 5 7 6 8 # Nix / devenv 7 9 /result
+26 -16
README.md
··· 5 5 Instead of piping opaque text between programs, gleshell pipelines pass typed values: strings, numbers, lists, records, and tables. Built-in commands like `ls`, `where`, and `select` work on that structure. 6 6 7 7 ```text 8 - gleshell:gleshell> ls | where type == file | select name size | first 5 8 + ~/code/gleshell on  main 9 + ❯ ls | where type == file | select name size | first 5 9 10 ╭──────┬──────╮ 10 11 │ name │ size │ 11 12 ├──────┼──────┤ ··· 13 14 ╰──────┴──────╯ 14 15 ``` 15 16 17 + The interactive prompt is zero-config and Starship-inspired: full path with `~`, 18 + optional git branch (read from `.git`, no external tools), and a `❯` character 19 + that turns red after a non-zero exit. 20 + 16 21 ## Quick start 17 22 18 23 Requires [Gleam](https://gleam.run/getting-started/) and Erlang (or use Nix): 19 24 20 25 ```bash 21 - # with Nix flake (dev shell: gleam, erlang, rebar3) 26 + # install a self-contained `gle` (Erlang shipment in the Nix store — no repo checkout) 27 + nix profile install . 28 + gle -c 'ls | first 3' 29 + 30 + # one-shot without installing 31 + nix run . -- -c 'ls | first 3' 32 + 33 + # dev shell (source tree; gleam, erlang, rebar3) 22 34 # --no-pure-eval lets devenv use the real project dir (not /nix/store) 23 35 nix develop --no-pure-eval 24 36 gleam run # interactive REPL 25 37 gleam run -- -c 'ls | first 3' 26 38 gleam test 27 - 28 - # or one-shot from the repo root 29 - nix run . -- -c 'ls | first 3' 30 - 31 - # or if gleam is already on PATH 32 - gleam run 33 39 ``` 34 40 35 41 ### REPL editing ··· 41 47 | Key | Action | 42 48 |-----|--------| 43 49 | ↑ / ↓ | History | 44 - | **Tab** | Filename completion (common prefix; list matches if ambiguous) | 50 + | **Tab** | Command completion (builtins + `PATH`) at the start of a pipeline stage; filename completion for arguments (common prefix; list matches if ambiguous) | 45 51 | **Ctrl+R** | Reverse-i-search through history (Enter accepts onto the line) | 46 52 | Ctrl+A / Ctrl+E | Beginning / end of line | 47 53 | Ctrl+W | Delete previous word | 48 54 | Ctrl+U / Ctrl+K | Kill to start / end of line | 49 55 | Ctrl+L | Clear screen | 50 - | **Ctrl+C** | Cancel current line (does not exit the shell) | 56 + | **Ctrl+C** | Cancel current line at the prompt; while an external command runs, send SIGINT to that process (does not exit the shell) | 51 57 | Ctrl+D | EOF (empty line) or delete under cursor | 52 58 53 59 History is persisted under the user cache as `gleshell-history/lines`. ··· 58 64 ```nu 59 65 # list files as a table, filter, project columns 60 66 ls | where type == file | select name size 67 + ls | find toml md 68 + echo [moe larry curly] | find l 61 69 62 70 # ranges and list ops 63 71 range 10 | reverse | first 3 ··· 106 114 107 115 Filesystem: `ls`, `cd`, `pwd`, `cat`, `open`, `save` 108 116 109 - Table/list: `where`/`filter`, `select`, `get`, `first`, `last`, `take`, `skip`, `sort-by`, `reverse`, `length`, `columns`, `table`, `flatten`, `uniq`, `wrap`, `unwrap`, `keys`, `values` 117 + Table/list: `where`/`filter`, `find`, `select`, `get`, `first`, `last`, `take`, `skip`, `sort-by`, `reverse`, `length`, `columns`, `table`, `flatten`, `uniq`, `wrap`, `unwrap`, `keys`, `values` 110 118 111 - Data: `echo`, `range`, `lines`, `to json`, `from json`, `type`, `describe`, `env`, `sys`, `which`, `help`, `exit` 119 + Data: `echo`, `range`, `lines`, `to`/`from` (subcommand `json`), `type`, `describe`, `env`, `sys`, `which`, `help`, `exit` 112 120 113 121 Unknown command names fall through to external executables on `PATH`. 114 122 ··· 131 139 132 140 Input and output are colorized on a TTY (Nu-like shapes on the command line; 133 141 headers bold green, numbers purple, bools cyan, dirs blue, errors red, …). 134 - External tools (`jj`, `git`, …) keep their own colors: final-stage commands 135 - inherit the real TTY (or get `FORCE_COLOR` when output is captured), and 136 - pre-colored text is not re-painted by the shell. Paginated output gets 137 - `LESS=FRX` when unset so `less` passes ANSI through. 142 + External tools (`jj`, `git`, `fastfetch`, …) keep their own colors: final-stage 143 + commands inherit the real TTY (or get `FORCE_COLOR` when output is captured), and 144 + pre-colored text is not re-painted by the shell. While the raw line editor is 145 + active, the TTY is switched to cooked mode for those children so LF-only 146 + output does not staircase. Paginated output gets `LESS=FRX` when unset so 147 + `less` passes ANSI through. 138 148 Disable with `NO_COLOR=1`; force with `FORCE_COLOR=1`. 139 149 140 150 ## Status
+104 -24
flake.nix
··· 31 31 system: 32 32 let 33 33 pkgs = nixpkgs.legacyPackages.${system}; 34 - runtimeInputs = [ 35 - pkgs.gleam 36 - pkgs.beamPackages.erlang 37 - pkgs.rebar3 38 - ]; 39 - # Thin wrapper: needs the gleshell source tree (cwd or GLESHELL_ROOT). 40 - gleshell = pkgs.writeShellApplication { 41 - name = "gleshell"; 42 - inherit runtimeInputs; 43 - text = '' 44 - root="''${GLESHELL_ROOT:-}" 45 - if [ -z "$root" ]; then 46 - if [ -f gleam.toml ] && grep -q 'name = "gleshell"' gleam.toml 2>/dev/null; then 47 - root="$PWD" 48 - else 49 - echo "gleshell: run from the gleshell repo root, or set GLESHELL_ROOT" >&2 50 - exit 1 51 - fi 52 - fi 53 - cd "$root" 54 - # +Bc: Ctrl+C cancels the line; do not open the Erlang BREAK/abort menu. 55 - export ERL_AFLAGS="+Bc ''${ERL_AFLAGS:-}" 56 - exec gleam run -- "$@" 34 + inherit (pkgs) lib; 35 + 36 + gleamToml = lib.importTOML ./gleam.toml; 37 + manifestToml = lib.importTOML ./manifest.toml; 38 + 39 + hexPackages = builtins.filter (p: p.source == "hex") manifestToml.packages; 40 + 41 + packagesTOML = lib.concatStringsSep "\n" ( 42 + [ "[packages]" ] ++ map (p: "${p.name} = \"${p.version}\"") hexPackages 43 + ); 44 + 45 + # Self-contained store package: compiles to Erlang shipment, wraps with 46 + # store-path Erlang. No source checkout / GLESHELL_ROOT required at runtime. 47 + gleshell = pkgs.stdenv.mkDerivation { 48 + pname = "gleshell"; 49 + version = gleamToml.version; 50 + 51 + src = lib.cleanSourceWith { 52 + src = ./.; 53 + filter = 54 + path: type: 55 + let 56 + base = baseNameOf path; 57 + in 58 + lib.cleanSourceFilter path type 59 + && base != "build" 60 + && base != "result" 61 + && base != ".devenv" 62 + && base != ".direnv" 63 + && base != "erl_crash.dump" 64 + # Root escript is a regular file; keep the src/gleshell/ directory. 65 + && !(type == "regular" && base == "gleshell") 66 + && !(lib.hasSuffix ".escript" base); 67 + }; 68 + 69 + nativeBuildInputs = [ 70 + pkgs.gleam 71 + pkgs.beamPackages.erlang 72 + pkgs.rebar3 73 + pkgs.beamPackages.hex 74 + pkgs.rsync 75 + ]; 76 + 77 + configurePhase = '' 78 + runHook preConfigure 79 + 80 + mkdir -p build/packages 81 + cat <<EOF > build/packages/packages.toml 82 + ${packagesTOML} 83 + EOF 84 + 85 + # Vendor Hex deps so the pure Nix build never hits the network. 86 + ${lib.concatMapStringsSep "\n" (p: '' 87 + rsync --chmod=Du=rwx,Dg=rx,Do=rx,Fu=rw,Fg=r,Fo=r -r ${ 88 + pkgs.fetchHex { 89 + pkg = p.name; 90 + version = p.version; 91 + sha256 = p.outer_checksum; 92 + } 93 + }/* build/packages/${p.name}/ 94 + '') hexPackages} 95 + 96 + runHook postConfigure 57 97 ''; 98 + 99 + buildPhase = '' 100 + runHook preBuild 101 + export REBAR_CACHE_DIR="$TMP/.rebar-cache" 102 + gleam export erlang-shipment 103 + runHook postBuild 104 + ''; 105 + 106 + installPhase = '' 107 + runHook preInstall 108 + 109 + mkdir -p $out/{bin,lib/gleshell} 110 + rsync --exclude=entrypoint.sh --exclude=entrypoint.ps1 \ 111 + -r build/erlang-shipment/* $out/lib/gleshell/ 112 + 113 + # Bake -pa paths at install time (each ebin needs its own -pa). 114 + ebin_args=() 115 + for d in $out/lib/gleshell/*/ebin; do 116 + ebin_args+=(-pa "$d") 117 + done 118 + 119 + # Runtime wrapper: fixed store paths, works from any cwd. 120 + # +Bc: Ctrl+C cancels the line; do not open the Erlang BREAK menu. 121 + { 122 + echo "#!${pkgs.runtimeShell}" 123 + echo "set -eu" 124 + echo 'export ERL_AFLAGS="+Bc ''${ERL_AFLAGS:-}"' 125 + echo "exec ${pkgs.beamPackages.erlang}/bin/erl ''${ebin_args[*]} -eval \"gleshell@@main:run(gleshell)\" -noshell -extra \"\$@\"" 126 + } > $out/bin/gle 127 + chmod +x $out/bin/gle 128 + ln -s gle $out/bin/gleshell 129 + 130 + runHook postInstall 131 + ''; 132 + 133 + meta = { 134 + description = gleamToml.description; 135 + mainProgram = "gle"; 136 + license = lib.licenses.asl20; 137 + }; 58 138 }; 59 139 in 60 140 { ··· 66 146 apps = forEachSystem (system: { 67 147 default = { 68 148 type = "app"; 69 - program = "${self.packages.${system}.gleshell}/bin/gleshell"; 149 + program = "${self.packages.${system}.gleshell}/bin/gle"; 70 150 }; 71 151 }); 72 152
+129 -23
src/gleshell.gleam
··· 1 1 //// gleshell — a structured-data shell in Gleam, inspired by Nushell. 2 2 3 3 import argv 4 + import filepath 4 5 import gleam/io 5 6 import gleam/string 6 7 import gleshell/color ··· 9 10 import gleshell/eval 10 11 import gleshell/sys 11 12 import gleshell/value.{Nothing} 13 + import simplifile 12 14 13 15 pub fn main() -> Nil { 14 16 case argv.load().arguments { ··· 63 65 64 66 fn repl(env: env.Env) -> Nil { 65 67 sys.println( 66 - "gleshell 0.1 — structured data shell (type `help`, `exit` to quit; Tab completes paths, Ctrl+R search)", 68 + "gleshell 0.1 — structured data shell (type `help`, `exit` to quit; Tab completes commands/paths, Ctrl+R search)", 67 69 ) 68 70 repl_loop(env) 69 71 } ··· 106 108 } 107 109 } 108 110 111 + /// Zero-config prompt inspired by Starship: 112 + /// blank line, directory (+ optional git branch), then a green/red `❯`. 113 + /// 114 + /// Status lines are printed once; only the character is the line-editor prompt 115 + /// (the raw editor redraws a single line). 109 116 fn prompt_for(env: env.Env) -> String { 110 - let base = basename(env.cwd) 111 117 let on = color.enabled() 112 - color.prompt_name(on, "gleshell") 113 - <> color.separator(on, ":") 114 - <> color.prompt_path(on, base) 115 - <> color.prompt_mark(on, "> ") 118 + let path = color.prompt_path(on, display_cwd(env.cwd)) 119 + let git = case git_branch(env.cwd) { 120 + Ok(branch) -> 121 + color.separator(on, " on ") 122 + <> color.prompt_git(on, " " <> branch) 123 + Error(Nil) -> "" 124 + } 125 + sys.println("") 126 + sys.println(path <> git) 127 + prompt_character(env.last_exit) 116 128 } 117 129 118 - fn basename(path: String) -> String { 119 - case string.split(path, "/") { 120 - [] -> path 121 - parts -> 122 - case list_last(parts) { 123 - "" -> 124 - // path ended with / 125 - case parts { 126 - [only] -> only 130 + fn prompt_character(last_exit: Int) -> String { 131 + let on = color.enabled() 132 + let mark = case last_exit { 133 + 0 -> color.prompt_character_ok(on, "❯") 134 + _ -> color.prompt_character_err(on, "❯") 135 + } 136 + mark <> " " 137 + } 138 + 139 + /// Full cwd for the prompt, with `$HOME` shown as `~`. 140 + fn display_cwd(cwd: String) -> String { 141 + case sys.home_dir() { 142 + Ok(home) -> abbreviate_home(cwd, home) 143 + Error(_) -> cwd 144 + } 145 + } 146 + 147 + fn abbreviate_home(path: String, home: String) -> String { 148 + case path == home { 149 + True -> "~" 150 + False -> 151 + case string.starts_with(path, home <> "/") { 152 + // Keep the leading `/` after home so `~/code` not `~code`. 153 + True -> "~" <> string.drop_start(path, string.length(home)) 154 + False -> path 155 + } 156 + } 157 + } 158 + 159 + /// Best-effort branch name by reading `.git` (no `git` process). 160 + fn git_branch(cwd: String) -> Result(String, Nil) { 161 + case find_git_dir(cwd, 32) { 162 + Error(Nil) -> Error(Nil) 163 + Ok(git_dir) -> read_git_head_branch(git_dir) 164 + } 165 + } 166 + 167 + fn find_git_dir(dir: String, budget: Int) -> Result(String, Nil) { 168 + case budget <= 0 { 169 + True -> Error(Nil) 170 + False -> { 171 + let candidate = filepath.join(dir, ".git") 172 + case simplifile.is_directory(candidate) { 173 + Ok(True) -> Ok(candidate) 174 + _ -> 175 + case simplifile.is_file(candidate) { 176 + Ok(True) -> read_gitdir_pointer(candidate) 127 177 _ -> { 128 - // take second last non-empty if possible 129 - path 178 + let parent = filepath.directory_name(dir) 179 + case parent == "" || parent == dir { 180 + True -> Error(Nil) 181 + False -> find_git_dir(parent, budget - 1) 182 + } 130 183 } 131 184 } 132 - name -> name 133 185 } 186 + } 134 187 } 135 188 } 136 189 137 - fn list_last(items: List(String)) -> String { 138 - case items { 139 - [] -> "" 140 - [x] -> x 141 - [_, ..rest] -> list_last(rest) 190 + /// Worktree / linked checkout: `.git` is a file `gitdir: <path>`. 191 + fn read_gitdir_pointer(git_file: String) -> Result(String, Nil) { 192 + case simplifile.read(git_file) { 193 + Error(_) -> Error(Nil) 194 + Ok(body) -> { 195 + let line = string.trim(first_line(body)) 196 + case string.starts_with(line, "gitdir:") { 197 + False -> Error(Nil) 198 + True -> { 199 + let raw = string.trim(string.drop_start(line, 7)) 200 + case raw { 201 + "" -> Error(Nil) 202 + path -> 203 + case filepath.is_absolute(path) { 204 + True -> Ok(path) 205 + False -> 206 + Ok(filepath.join(filepath.directory_name(git_file), path)) 207 + } 208 + } 209 + } 210 + } 211 + } 212 + } 213 + } 214 + 215 + fn read_git_head_branch(git_dir: String) -> Result(String, Nil) { 216 + case simplifile.read(filepath.join(git_dir, "HEAD")) { 217 + Error(_) -> Error(Nil) 218 + Ok(body) -> { 219 + let line = string.trim(first_line(body)) 220 + case string.starts_with(line, "ref: ") { 221 + True -> { 222 + let ref = string.trim(string.drop_start(line, 5)) 223 + // Prefer short branch name: refs/heads/main → main 224 + case string.starts_with(ref, "refs/heads/") { 225 + True -> Ok(string.drop_start(ref, 11)) 226 + False -> Ok(filepath.base_name(ref)) 227 + } 228 + } 229 + // Detached HEAD — show a short SHA when it looks like one. 230 + False -> 231 + case string.length(line) >= 7 { 232 + True -> Ok(string.slice(line, 0, 7)) 233 + False -> 234 + case line { 235 + "" -> Error(Nil) 236 + other -> Ok(other) 237 + } 238 + } 239 + } 240 + } 241 + } 242 + } 243 + 244 + fn first_line(s: String) -> String { 245 + case string.split_once(s, "\n") { 246 + Ok(#(line, _)) -> line 247 + Error(Nil) -> s 142 248 } 143 249 } 144 250
+480 -18
src/gleshell/builtins.gleam
··· 38 38 #("save", cmd_save), 39 39 #("where", cmd_where), 40 40 #("filter", cmd_where), 41 + #("find", cmd_find), 41 42 #("select", cmd_select), 42 43 #("get", cmd_get), 43 44 #("first", cmd_first), ··· 52 53 #("uniq", cmd_uniq), 53 54 #("wrap", cmd_wrap), 54 55 #("unwrap", cmd_unwrap), 55 - // Nushell multi-word: `to json` / `from json` 56 - #("to json", cmd_to_json), 57 - #("from json", cmd_from_json), 56 + // Nushell-style: `to` / `from` with format subcommands (`json`) 57 + #("to", cmd_to), 58 + #("from", cmd_from), 58 59 #("lines", cmd_lines), 59 60 #("typeof", cmd_type), 60 61 #("type", cmd_type), ··· 85 86 |> list.sort(string.compare) 86 87 } 87 88 89 + /// Registered builtins that lack a dedicated `help_text` entry (should be empty). 90 + pub fn missing_help() -> List(String) { 91 + list.filter(names(), fn(n) { !dict.has_key(help_text(), n) }) 92 + } 93 + 88 94 fn ok(env: Env, value: Value) -> BuiltinResult { 89 95 BuiltinResult(env, value) 90 96 } ··· 103 109 ) -> BuiltinResult { 104 110 case args { 105 111 [String(name)] -> 106 - case dict.get(help_text(), name) { 112 + case help_for(name) { 107 113 Ok(text) -> ok(env, String(text)) 108 114 Error(Nil) -> err(env, "unknown command: " <> name) 109 115 } 110 116 _ -> { 117 + let command_lines = 118 + list.map(names(), fn(n) { 119 + case help_line(n) { 120 + Ok(text) -> " " <> text 121 + Error(Nil) -> " " <> n 122 + } 123 + }) 111 124 let lines = 112 125 list.append( 113 126 [ ··· 120 133 "", 121 134 "Commands:", 122 135 ], 123 - list.append(list.map(names(), fn(n) { " " <> n }), [ 136 + list.append(command_lines, [ 124 137 "", 125 138 "Use `help <command>` for details. `^cmd` forces an external binary.", 126 139 "Variables: `let x = ...` then `$x`. Pipeline input is `$in`.", ··· 132 145 } 133 146 } 134 147 148 + /// One-line help for a registered builtin, or Error if the name is not a builtin. 149 + fn help_line(name: String) -> Result(String, Nil) { 150 + case dict.get(help_text(), name) { 151 + Ok(text) -> Ok(text) 152 + Error(Nil) -> 153 + case dict.has_key(registry(), name) { 154 + // Safety net if help_text drifts; `missing_help` / tests should catch this. 155 + True -> Ok(name <> " — builtin command") 156 + False -> Error(Nil) 157 + } 158 + } 159 + } 160 + 161 + /// Full help text for `help <name>`, including subcommands where relevant. 162 + fn help_for(name: String) -> Result(String, Nil) { 163 + case name { 164 + "to" -> 165 + Ok(string.join( 166 + [ 167 + "to <format> — convert pipeline input to a text format", 168 + "", 169 + "Subcommands:", 170 + " json [--raw|-r] [--indent|-i n] — JSON string (pretty by default;", 171 + " --raw is compact, no trailing newline)", 172 + "", 173 + "Examples:", 174 + " range 3 | to json", 175 + " ls | to json --raw", 176 + ], 177 + "\n", 178 + )) 179 + "from" -> 180 + Ok(string.join( 181 + [ 182 + "from <format> — parse text input into structured data", 183 + "", 184 + "Subcommands:", 185 + " json — parse a JSON string (pipeline input or a string argument)", 186 + "", 187 + "Examples:", 188 + " open data.json | from json", 189 + " echo '{\"a\": 1}' | from json | get a", 190 + ], 191 + "\n", 192 + )) 193 + "find" -> 194 + Ok(string.join( 195 + [ 196 + "find [-i] [-v] [--regex pat] [--columns cols] <term>… — search pipeline input", 197 + "", 198 + "Filters lists/tables for items matching any term (OR). Strings use", 199 + "substring match; numbers/bools match by equality. Multi-line strings", 200 + "are split into lines (unless --multiline).", 201 + "", 202 + "Flags:", 203 + " -i, --ignore-case case-insensitive match", 204 + " -v, --invert keep non-matching items", 205 + " -r, --regex <pat> Erlang regex (not combined with terms)", 206 + " -c, --columns <list> only search these table columns", 207 + " -m, --multiline do not split multi-line strings into lines", 208 + "", 209 + "Examples:", 210 + " ls | find toml md", 211 + " echo [moe larry curly] | find l", 212 + " echo [Hello world] | find hello -i", 213 + " echo [abc odb abf] | find --regex \"b.\"", 214 + ], 215 + "\n", 216 + )) 217 + _ -> help_line(name) 218 + } 219 + } 220 + 135 221 fn help_text() -> dict.Dict(String, String) { 136 222 dict.from_list([ 223 + #("help", "help [command] — list builtins, or show help for one command"), 224 + #("echo", "echo <values>… — emit values (list if multiple)"), 225 + #("print", "print <values>… — alias for echo"), 137 226 #("ls", "ls [path] — list directory entries as a table"), 227 + #("pwd", "pwd — print working directory"), 138 228 #("cd", "cd [path] — change directory (~ supported)"), 139 - #("pwd", "pwd — print working directory"), 140 229 #("cat", "cat <path> — read file as string"), 141 230 #("open", "open <path> — open file; parses .json into structured data"), 142 231 #("save", "save <path> — save pipeline input to a file"), ··· 144 233 "where", 145 234 "where <field> <op> <value> — filter rows (ops: == != > < >= <=)", 146 235 ), 236 + #("filter", "filter <field> <op> <value> — alias for where"), 237 + #( 238 + "find", 239 + "find [-i] [-v] [--regex pat] <term>… — search list/table/string input for terms", 240 + ), 147 241 #("select", "select <col>… — keep only named columns"), 148 242 #("get", "get <field|index> — get a field or list index"), 149 243 #("first", "first [n] — first row/item (default 1)"), 150 244 #("last", "last [n] — last row/item"), 151 - #("take", "take <n> — take first n rows"), 152 - #("skip", "skip <n> — skip first n rows"), 153 - #("echo", "echo <values>… — emit values (list if multiple)"), 245 + #("take", "take <n> — take first n items"), 246 + #("skip", "skip <n> — skip first n items"), 247 + #("length", "length — number of items in list/table/string input"), 248 + #("count", "count — alias for length"), 249 + #("reverse", "reverse — reverse list, table rows, or string graphemes"), 250 + #("sort-by", "sort-by <field> — sort table rows by field"), 251 + #("sort_by", "sort_by <field> — alias for sort-by"), 252 + #("uniq", "uniq — drop duplicate list items (order preserved)"), 253 + #("wrap", "wrap <name> — wrap pipeline input as a single-field record"), 254 + #("unwrap", "unwrap [name] — unwrap a record field (default: first field)"), 255 + #( 256 + "to", 257 + "to <format> — convert pipeline input (subcommands: json)", 258 + ), 154 259 #( 155 - "to json", 156 - "to json [--raw|-r] [--indent|-i n] — convert input to JSON string (pretty by default)", 260 + "from", 261 + "from <format> — parse structured input (subcommands: json)", 157 262 ), 263 + #("lines", "lines — split string input into a list of lines"), 264 + #("typeof", "typeof — type name of pipeline input"), 265 + #("type", "type — alias for typeof"), 158 266 #( 159 - "from json", 160 - "from json — parse JSON string input into structured data", 267 + "describe", 268 + "describe — record with type, length, and string form of input", 161 269 ), 162 - #("range", "range <end> | range <start> <end> — integer range list"), 163 270 #( 164 271 "env", 165 272 "env [NAME] — process environment table, or one var (same as `$env` / `$env.NAME`)", 166 273 ), 167 - #("sys", "sys — host info record"), 168 274 #( 169 275 "which", 170 276 "which [-a|--all] <name> — path of command (builtin or on PATH); -a lists all matches", 171 277 ), 278 + #("exit", "exit [code] — leave the shell (default code 0)"), 279 + #("quit", "quit [code] — alias for exit"), 280 + #("ignore", "ignore — discard pipeline input; emit nothing"), 281 + #("identity", "identity — pass pipeline input through unchanged"), 282 + #("range", "range <end> | range <start> <end> — integer range list"), 283 + #("append", "append <values>… — append values to list input"), 284 + #("prepend", "prepend <values>… — prepend values to list input"), 285 + #("is-empty", "is-empty — true if list/table/string input has length 0"), 286 + #("is_empty", "is_empty — alias for is-empty"), 287 + #( 288 + "table", 289 + "table — coerce list of records (or table) into a table", 290 + ), 291 + #("columns", "columns — column names of a table, or keys of a record"), 292 + #("flatten", "flatten — one level of list-of-lists flattening"), 293 + #("values", "values — list of values from a record"), 294 + #("keys", "keys — list of keys from a record"), 295 + #("sys", "sys — host info record (cwd, home, shell, last_exit)"), 172 296 ]) 173 297 } 174 298 ··· 413 537 } 414 538 } 415 539 540 + // --- find (Nushell-style search filter) --- 541 + 542 + fn cmd_find( 543 + env: Env, 544 + input: Value, 545 + args: List(Value), 546 + flags: dict.Dict(String, Value), 547 + ) -> BuiltinResult { 548 + // Boolean flags may steal the next word (`find -i hello` → flag i = "hello"). 549 + let #(ignore_case, stolen_i) = 550 + find_bool_flag(flags, ["i", "ignore-case"]) 551 + let #(invert, stolen_v) = find_bool_flag(flags, ["v", "invert"]) 552 + let #(multiline, stolen_m) = find_bool_flag(flags, ["m", "multiline"]) 553 + let #(_, stolen_n) = find_bool_flag(flags, ["n", "no-highlight"]) 554 + let #(_, stolen_s) = find_bool_flag(flags, ["s", "dotall"]) 555 + let #(_, stolen_rfind) = find_bool_flag(flags, ["R", "rfind"]) 556 + 557 + let regex_opt = find_regex_pattern(flags) 558 + let columns = find_columns(flags) 559 + 560 + let terms = 561 + list.flatten([ 562 + args, 563 + stolen_i, 564 + stolen_v, 565 + stolen_m, 566 + stolen_n, 567 + stolen_s, 568 + stolen_rfind, 569 + ]) 570 + 571 + case terms, regex_opt { 572 + [], option.None -> 573 + err(env, "find: expected search term(s) or --regex <pattern>") 574 + _, option.Some(Error(msg)) -> err(env, "find: " <> msg) 575 + [_, ..], option.Some(Ok(_)) -> 576 + err(env, "find: cannot use --regex with additional search terms") 577 + terms, regex_opt -> { 578 + let pattern = case regex_opt { 579 + option.Some(Ok(p)) -> option.Some(p) 580 + _ -> option.None 581 + } 582 + case 583 + find_filter( 584 + input, 585 + terms, 586 + pattern, 587 + ignore_case, 588 + invert, 589 + multiline, 590 + columns, 591 + ) 592 + { 593 + Ok(v) -> ok(env, v) 594 + Error(msg) -> err(env, "find: " <> msg) 595 + } 596 + } 597 + } 598 + } 599 + 600 + /// Parse a boolean flag that may have stolen a following value as its arg. 601 + /// Returns `(flag_set, stolen_terms)`. 602 + fn find_bool_flag( 603 + flags: dict.Dict(String, Value), 604 + names: List(String), 605 + ) -> #(Bool, List(Value)) { 606 + list.fold(names, #(False, []), fn(acc, name) { 607 + let #(_set, stolen) = acc 608 + case dict.get(flags, name) { 609 + Error(Nil) -> acc 610 + Ok(Bool(False)) | Ok(Nothing) -> acc 611 + Ok(Bool(True)) -> #(True, stolen) 612 + Ok(v) -> #(True, list.append(stolen, [v])) 613 + } 614 + }) 615 + } 616 + 617 + fn find_regex_pattern( 618 + flags: dict.Dict(String, Value), 619 + ) -> option.Option(Result(String, String)) { 620 + case dict.get(flags, "regex"), dict.get(flags, "r") { 621 + Ok(String(p)), _ -> option.Some(Ok(p)) 622 + _, Ok(String(p)) -> option.Some(Ok(p)) 623 + Ok(Bool(True)), _ | _, Ok(Bool(True)) -> 624 + option.Some(Error("regex flag requires a pattern (try `find --regex <pat>`)")) 625 + Ok(other), _ -> 626 + option.Some(Ok(value.as_string(other))) 627 + _, Ok(other) -> option.Some(Ok(value.as_string(other))) 628 + Error(Nil), Error(Nil) -> option.None 629 + } 630 + } 631 + 632 + fn find_columns(flags: dict.Dict(String, Value)) -> option.Option(List(String)) { 633 + case dict.get(flags, "columns"), dict.get(flags, "c") { 634 + Ok(v), _ -> columns_from_value(v) 635 + _, Ok(v) -> columns_from_value(v) 636 + Error(Nil), Error(Nil) -> option.None 637 + } 638 + } 639 + 640 + fn columns_from_value(v: Value) -> option.Option(List(String)) { 641 + case v { 642 + List(items) -> { 643 + let cols = 644 + list.filter_map(items, fn(item) { 645 + case item { 646 + String(s) -> Ok(s) 647 + other -> Ok(value.as_string(other)) 648 + } 649 + }) 650 + option.Some(cols) 651 + } 652 + String(s) -> option.Some([s]) 653 + _ -> option.Some([value.as_string(v)]) 654 + } 655 + } 656 + 657 + fn find_filter( 658 + input: Value, 659 + terms: List(Value), 660 + regex: option.Option(String), 661 + ignore_case: Bool, 662 + invert: Bool, 663 + multiline: Bool, 664 + columns: option.Option(List(String)), 665 + ) -> Result(Value, String) { 666 + case input { 667 + List(items) -> { 668 + case filter_items(items, terms, regex, ignore_case, invert, columns) { 669 + Ok(kept) -> Ok(List(kept)) 670 + Error(e) -> Error(e) 671 + } 672 + } 673 + Table(_, _) -> 674 + case value.table_to_records(input) { 675 + Error(e) -> Error(e) 676 + Ok(rows) -> 677 + case filter_items(rows, terms, regex, ignore_case, invert, columns) { 678 + Ok(kept) -> Ok(value.table_from_records(kept)) 679 + Error(e) -> Error(e) 680 + } 681 + } 682 + String(s) -> 683 + case multiline || !string.contains(s, "\n") { 684 + True -> 685 + case item_matches(String(s), terms, regex, ignore_case, option.None) { 686 + Error(e) -> Error(e) 687 + Ok(matched) -> 688 + case matched != invert { 689 + True -> Ok(String(s)) 690 + False -> Ok(Nothing) 691 + } 692 + } 693 + False -> { 694 + let lines = list.map(string.split(s, "\n"), String) 695 + case filter_items(lines, terms, regex, ignore_case, invert, option.None) { 696 + Ok(kept) -> Ok(List(kept)) 697 + Error(e) -> Error(e) 698 + } 699 + } 700 + } 701 + Nothing -> Error("pipeline input is required (try `ls | find term`)") 702 + other -> 703 + // Single scalar / record: keep if it matches, else nothing. 704 + case item_matches(other, terms, regex, ignore_case, columns) { 705 + Error(e) -> Error(e) 706 + Ok(matched) -> 707 + case matched != invert { 708 + True -> Ok(other) 709 + False -> Ok(Nothing) 710 + } 711 + } 712 + } 713 + } 714 + 715 + fn filter_items( 716 + items: List(Value), 717 + terms: List(Value), 718 + regex: option.Option(String), 719 + ignore_case: Bool, 720 + invert: Bool, 721 + columns: option.Option(List(String)), 722 + ) -> Result(List(Value), String) { 723 + list.try_fold(items, [], fn(acc, item) { 724 + case item_matches(item, terms, regex, ignore_case, columns) { 725 + Error(e) -> Error(e) 726 + Ok(matched) -> 727 + case matched != invert { 728 + True -> Ok(list.append(acc, [item])) 729 + False -> Ok(acc) 730 + } 731 + } 732 + }) 733 + } 734 + 735 + fn item_matches( 736 + item: Value, 737 + terms: List(Value), 738 + regex: option.Option(String), 739 + ignore_case: Bool, 740 + columns: option.Option(List(String)), 741 + ) -> Result(Bool, String) { 742 + case item { 743 + Record(fields) -> { 744 + let fields = case columns { 745 + option.None -> fields 746 + option.Some(cols) -> 747 + list.filter(fields, fn(pair) { 748 + let #(k, _) = pair 749 + list.contains(cols, k) 750 + }) 751 + } 752 + // Match if any selected field matches (OR), or exact record equality. 753 + case list.any(terms, fn(t) { value.equals(item, t) }) { 754 + True -> Ok(True) 755 + False -> 756 + list.try_fold(fields, False, fn(acc, pair) { 757 + case acc { 758 + True -> Ok(True) 759 + False -> { 760 + let #(_, v) = pair 761 + item_matches(v, terms, regex, ignore_case, option.None) 762 + } 763 + } 764 + }) 765 + } 766 + } 767 + List(inner) -> { 768 + // Nested list: match if any element matches, or the rendered text does. 769 + case 770 + list.try_fold(inner, False, fn(acc, v) { 771 + case acc { 772 + True -> Ok(True) 773 + False -> item_matches(v, terms, regex, ignore_case, option.None) 774 + } 775 + }) 776 + { 777 + Error(e) -> Error(e) 778 + Ok(True) -> Ok(True) 779 + Ok(False) -> 780 + text_matches(value.as_string(item), terms, regex, ignore_case) 781 + } 782 + } 783 + // Scalars: exact equality always; strings also allow substring contains. 784 + // Numbers/bools do not substring-match (Nu: `find 5` does not keep 35). 785 + String(s) -> text_matches(s, terms, regex, ignore_case) 786 + Int(_) | Float(_) | Bool(_) -> scalar_matches(item, terms, regex, ignore_case) 787 + Nothing -> scalar_matches(item, terms, regex, ignore_case) 788 + other -> { 789 + case list.any(terms, fn(t) { value.equals(other, t) }) { 790 + True -> Ok(True) 791 + False -> text_matches(value.as_string(other), terms, regex, ignore_case) 792 + } 793 + } 794 + } 795 + } 796 + 797 + fn scalar_matches( 798 + scalar: Value, 799 + terms: List(Value), 800 + regex: option.Option(String), 801 + ignore_case: Bool, 802 + ) -> Result(Bool, String) { 803 + case regex { 804 + option.Some(pattern) -> 805 + text_matches( 806 + value.as_string(scalar), 807 + terms, 808 + option.Some(pattern), 809 + ignore_case, 810 + ) 811 + option.None -> Ok(list.any(terms, fn(t) { value.equals(scalar, t) })) 812 + } 813 + } 814 + 815 + fn text_matches( 816 + text: String, 817 + terms: List(Value), 818 + regex: option.Option(String), 819 + ignore_case: Bool, 820 + ) -> Result(Bool, String) { 821 + case regex { 822 + option.Some(pattern) -> 823 + case sys.re_contains(text, pattern, ignore_case) { 824 + Ok(b) -> Ok(b) 825 + Error(msg) -> Error("invalid regex: " <> msg) 826 + } 827 + option.None -> { 828 + let haystack = case ignore_case { 829 + True -> string.lowercase(text) 830 + False -> text 831 + } 832 + Ok( 833 + list.any(terms, fn(term) { 834 + let needle = case ignore_case { 835 + True -> string.lowercase(value.as_string(term)) 836 + False -> value.as_string(term) 837 + } 838 + case needle { 839 + "" -> True 840 + _ -> string.contains(haystack, needle) 841 + } 842 + }), 843 + ) 844 + } 845 + } 846 + } 847 + 416 848 fn cmd_select( 417 849 env: Env, 418 850 input: Value, ··· 723 1155 } 724 1156 } 725 1157 726 - // --- json (mirrors Nushell `to json` / `from json`) --- 1158 + // --- to / from (Nushell-style format commands with subcommands) --- 1159 + 1160 + fn cmd_to( 1161 + env: Env, 1162 + input: Value, 1163 + args: List(Value), 1164 + flags: dict.Dict(String, Value), 1165 + ) -> BuiltinResult { 1166 + case args { 1167 + [String("json"), ..rest] -> cmd_to_json(env, input, rest, flags) 1168 + [] -> 1169 + err(env, "to: expected subcommand (try `to json`; see `help to`)") 1170 + [String(sub), ..] -> err(env, "to: unknown subcommand: " <> sub) 1171 + _ -> err(env, "to: expected subcommand name") 1172 + } 1173 + } 1174 + 1175 + fn cmd_from( 1176 + env: Env, 1177 + input: Value, 1178 + args: List(Value), 1179 + flags: dict.Dict(String, Value), 1180 + ) -> BuiltinResult { 1181 + case args { 1182 + [String("json"), ..rest] -> cmd_from_json(env, input, rest, flags) 1183 + [] -> 1184 + err(env, "from: expected subcommand (try `from json`; see `help from`)") 1185 + [String(sub), ..] -> err(env, "from: unknown subcommand: " <> sub) 1186 + _ -> err(env, "from: expected subcommand name") 1187 + } 1188 + } 727 1189 728 1190 fn cmd_to_json( 729 1191 env: Env, ··· 770 1232 _ -> "" 771 1233 } 772 1234 case source { 773 - "" -> err(env, "from json: empty input") 1235 + "" -> err(env, "from: json: empty input") 774 1236 s -> 775 1237 case parse_json_value(s) { 776 1238 Ok(v) -> ok(env, v) 777 - Error(msg) -> err(env, "from json: " <> msg) 1239 + Error(msg) -> err(env, "from: json: " <> msg) 778 1240 } 779 1241 } 780 1242 }
+17 -1
src/gleshell/color.gleam
··· 172 172 paint(on, bold_green, text) 173 173 } 174 174 175 + /// Directory segment (bold cyan — Starship-inspired). 175 176 pub fn prompt_path(on: Bool, text: String) -> String { 176 - paint(on, bright_blue, text) 177 + paint(on, bold_cyan, text) 178 + } 179 + 180 + /// Git branch segment (bold purple — Starship-inspired). 181 + pub fn prompt_git(on: Bool, text: String) -> String { 182 + paint(on, bold_purple, text) 177 183 } 178 184 179 185 pub fn prompt_mark(on: Bool, text: String) -> String { 180 186 paint(on, bold, text) 187 + } 188 + 189 + /// Success character (bold green `❯`). 190 + pub fn prompt_character_ok(on: Bool, text: String) -> String { 191 + paint(on, "\u{001b}[1;32m", text) 192 + } 193 + 194 + /// Error character after non-zero exit (bold red `❯`). 195 + pub fn prompt_character_err(on: Bool, text: String) -> String { 196 + paint(on, bold_red, text) 181 197 } 182 198 183 199 // --- syntax shapes (Nushell `shape_*` defaults) ---
+56 -3
src/gleshell/display.gleam
··· 97 97 let #(k, v) = pair 98 98 let key = color.key(on, pad_right(k, key_w)) 99 99 let bar = color.separator(on, "│") 100 - let cell = 101 - color_cell_for_column(on, k, v, value.cell_string(v), type_hint) 100 + let plain = cell_plain(k, v) 101 + let cell = color_cell_for_column(on, k, v, plain, type_hint) 102 102 " " <> key <> " " <> bar <> " " <> cell 103 103 }) 104 104 let top = color.separator(on, "╭──── record ───") ··· 120 120 case columns { 121 121 [] -> color.nothing(on, "(empty table)") 122 122 _ -> { 123 + // Column-aware plain text (e.g. size → KB/MB) for widths and coloring. 123 124 let plain_cells: List(List(String)) = 124 - list.map(rows, fn(row) { list.map(row, value.cell_string) }) 125 + list.map(rows, fn(row) { 126 + list.index_map(columns, fn(col, i) { 127 + case list_at(row, i) { 128 + Ok(v) -> cell_plain(col, v) 129 + Error(Nil) -> "" 130 + } 131 + }) 132 + }) 125 133 126 134 // Widths use visible length so cells with ANSI (from external tools) 127 135 // do not inflate the table. ··· 243 251 True -> Ok(i) 244 252 False -> list_index_of_loop(rest, target, i + 1) 245 253 } 254 + } 255 + } 256 + 257 + /// Plain cell text before coloring. Keeps pipeline data as raw ints; only 258 + /// display converts `size` byte counts to KB/MB/…. 259 + fn cell_plain(col: String, value: Value) -> String { 260 + case col, value { 261 + "size", Int(n) -> format_filesize(n) 262 + _, _ -> value.cell_string(value) 263 + } 264 + } 265 + 266 + /// Format a byte count for display: B below 1 KiB, then KB / MB / GB / TB 267 + /// (1024-based). One decimal place when the fractional part is non-zero. 268 + pub fn format_filesize(bytes: Int) -> String { 269 + let n = case bytes < 0 { 270 + True -> 0 271 + False -> bytes 272 + } 273 + case n < 1024 { 274 + True -> int.to_string(n) <> " B" 275 + False -> 276 + case n < 1_048_576 { 277 + True -> scale_unit(n, 1024, " KB") 278 + False -> 279 + case n < 1_073_741_824 { 280 + True -> scale_unit(n, 1_048_576, " MB") 281 + False -> 282 + case n < 1_099_511_627_776 { 283 + True -> scale_unit(n, 1_073_741_824, " GB") 284 + False -> scale_unit(n, 1_099_511_627_776, " TB") 285 + } 286 + } 287 + } 288 + } 289 + } 290 + 291 + fn scale_unit(n: Int, unit: Int, suffix: String) -> String { 292 + // tenths with half-up rounding: (n * 10 + unit/2) / unit 293 + let tenths = { n * 10 + unit / 2 } / unit 294 + let whole = tenths / 10 295 + let frac = tenths % 10 296 + case frac { 297 + 0 -> int.to_string(whole) <> suffix 298 + _ -> int.to_string(whole) <> "." <> int.to_string(frac) <> suffix 246 299 } 247 300 } 248 301
+19 -27
src/gleshell/eval.gleam
··· 116 116 True -> 117 117 case eval_argv(env, args) { 118 118 Error(msg) -> Continue(env.set_exit(env, 1), Fail(msg)) 119 - Ok(str_args) -> run_external(env, name, str_args, interactive) 119 + Ok(str_args) -> run_external(env, name, str_args, input, interactive) 120 120 } 121 121 False -> 122 122 case eval_args(env, args) { ··· 124 124 Ok(#(pos, flags)) -> { 125 125 // Builtins produce a new value that was not streamed to the TTY. 126 126 sys.clear_output_shown() 127 - case resolve_builtin(name, pos) { 128 - Ok(#(builtin, pos2)) -> 129 - case builtin(env, input, pos2, flags) { 127 + case dict.get(builtins.registry(), name) { 128 + Ok(builtin) -> 129 + case builtin(env, input, pos, flags) { 130 130 builtins.Exit(code) -> Quit(code) 131 131 builtins.BuiltinResult(env2, value) -> { 132 132 let env2 = case value { ··· 141 141 case eval_argv(env, args) { 142 142 Error(msg) -> Continue(env.set_exit(env, 1), Fail(msg)) 143 143 Ok(str_args) -> 144 - run_external(env, name, str_args, interactive) 144 + run_external(env, name, str_args, input, interactive) 145 145 } 146 146 } 147 147 } 148 148 } 149 149 } 150 150 } 151 - } 152 - } 153 - 154 - /// Look up a builtin, including Nushell-style multi-word names (`to json`). 155 - /// When `name` alone is missing, try consuming a following bare string arg. 156 - fn resolve_builtin( 157 - name: String, 158 - pos: List(Value), 159 - ) -> Result(#(builtins.Builtin, List(Value)), Nil) { 160 - case dict.get(builtins.registry(), name) { 161 - Ok(builtin) -> Ok(#(builtin, pos)) 162 - Error(Nil) -> 163 - case pos { 164 - [String(sub), ..rest] -> 165 - case dict.get(builtins.registry(), name <> " " <> sub) { 166 - Ok(builtin) -> Ok(#(builtin, rest)) 167 - Error(Nil) -> Error(Nil) 168 - } 169 - _ -> Error(Nil) 170 - } 171 151 } 172 152 } 173 153 ··· 261 241 env: Env, 262 242 name: String, 263 243 str_args: List(String), 244 + input: Value, 264 245 interactive: Bool, 265 246 ) -> EvalResult { 247 + // Pipeline input becomes the external's stdin (Unix-style `cmd | less`). 248 + let stdin = stdin_bytes(input) 266 249 let result = case interactive { 267 - True -> sys.run_cmd_tty(name, str_args) 268 - False -> sys.run_cmd(name, str_args) 250 + True -> sys.run_cmd_tty(name, str_args, stdin) 251 + False -> sys.run_cmd(name, str_args, stdin) 269 252 } 270 253 case result { 271 254 Error(msg) -> Continue(env.set_exit(env, 127), Fail(msg)) ··· 283 266 } 284 267 } 285 268 } 269 + 270 + /// Bytes fed to an external's stdin from the previous pipeline stage. 271 + fn stdin_bytes(input: Value) -> String { 272 + case input { 273 + Nothing -> "" 274 + String(s) -> s 275 + other -> value.as_string(other) 276 + } 277 + }
-40
src/gleshell/highlight.gleam
··· 360 360 builtins: List(String), 361 361 acc: String, 362 362 ) -> String { 363 - // Multi-word builtins: `to json`, `from json` 364 - case word { 365 - "to" | "from" -> { 366 - let #(ws, rest1) = take_space(after) 367 - let #(next, rest2) = take_ident(rest1) 368 - case next { 369 - "json" -> { 370 - let full = word <> ws <> next 371 - paint_chars( 372 - rest2, 373 - ExpectArg, 374 - builtins, 375 - acc <> color.shape_internalcall(True, full), 376 - ) 377 - } 378 - _ -> paint_single_command(word, after, builtins, acc) 379 - } 380 - } 381 - _ -> paint_single_command(word, after, builtins, acc) 382 - } 383 - } 384 - 385 - fn paint_single_command( 386 - word: String, 387 - after: List(String), 388 - builtins: List(String), 389 - acc: String, 390 - ) -> String { 391 363 let painted = case list.contains(builtins, word) { 392 364 True -> color.shape_internalcall(True, word) 393 365 False -> color.shape_external(True, word) ··· 399 371 case expect { 400 372 ExpectCommand -> ExpectArg 401 373 ExpectArg -> ExpectArg 402 - } 403 - } 404 - 405 - fn take_space(chars: List(String)) -> #(String, List(String)) { 406 - take_space_loop(chars, "") 407 - } 408 - 409 - fn take_space_loop(chars: List(String), acc: String) -> #(String, List(String)) { 410 - case chars { 411 - [" ", ..rest] -> take_space_loop(rest, acc <> " ") 412 - ["\t", ..rest] -> take_space_loop(rest, acc <> "\t") 413 - _ -> #(acc, chars) 414 374 } 415 375 } 416 376
+26 -2
src/gleshell/sys.gleam
··· 30 30 pub fn list_env() -> List(#(String, String)) 31 31 32 32 /// Run an external command capturing stdout/stderr (pipelines, `let`, non-TTY). 33 + /// `stdin` is fed to the process (empty → `/dev/null`). 33 34 @external(erlang, "gleshell_ffi", "run_cmd") 34 35 pub fn run_cmd( 35 36 command: String, 36 37 args: List(String), 38 + stdin: String, 37 39 ) -> Result(#(Int, String), String) 38 40 39 - /// Run an external command in the foreground on the real TTY when possible 40 - /// (`less`, `vim`, …). Falls back to capture when stdout is not a terminal. 41 + /// Run an external command in the foreground on a TTY when possible 42 + /// (`less`, `vim`, `bat`, `fastfetch`, …). Prefers a PTY (`script`) so keys — 43 + /// including Ctrl+C → SIGINT — reach the child. Pipeline `stdin` is still fed 44 + /// when non-empty (e.g. `cat file | less`). Falls back to capture when stdout 45 + /// is not a terminal, or to plain inherit if `script` is unavailable. 46 + /// 47 + /// In the raw-mode REPL the controlling TTY is briefly switched to cooked 48 + /// termios (with ISIG off) for the child and restored after, so LF-only output 49 + /// does not staircase and Ctrl+C does not open the Erlang BREAK menu. 41 50 @external(erlang, "gleshell_ffi", "run_cmd_tty") 42 51 pub fn run_cmd_tty( 43 52 command: String, 44 53 args: List(String), 54 + stdin: String, 45 55 ) -> Result(#(Int, String), String) 46 56 47 57 /// True if the last external command already streamed its output to the TTY ··· 59 69 /// All matching executables on `PATH` (or the path itself if it contains `/`). 60 70 @external(erlang, "gleshell_ffi", "which_all") 61 71 pub fn which_all(command: String) -> List(String) 72 + 73 + /// True if `text` matches Erlang regex `pattern`. `ignore_case` enables caseless. 74 + /// Error string on invalid pattern. 75 + @external(erlang, "gleshell_ffi", "re_contains") 76 + pub fn re_contains( 77 + text: String, 78 + pattern: String, 79 + ignore_case: Bool, 80 + ) -> Result(Bool, String) 81 + 82 + /// Tab-completion candidates for `word` given the text before it on the line. 83 + /// Returns `(matches, kind)` where kind is `"command"` or `"path"`. 84 + @external(erlang, "gleshell_ffi", "complete_word") 85 + pub fn complete_word(prefix: String, word: String) -> #(List(String), String) 62 86 63 87 @external(erlang, "gleshell_ffi", "home_dir") 64 88 pub fn home_dir() -> Result(String, String)
+862 -98
src/gleshell_ffi.erl
··· 11 11 getenv/1, 12 12 setenv/2, 13 13 list_env/0, 14 - run_cmd/2, 15 - run_cmd_tty/2, 14 + run_cmd/3, 15 + run_cmd_tty/3, 16 16 which/1, 17 17 which_all/1, 18 18 home_dir/0, 19 19 stdout_isatty/0, 20 20 println/1, 21 21 take_output_shown/0, 22 - clear_output_shown/0 22 + clear_output_shown/0, 23 + complete_word/2, 24 + re_contains/3 23 25 ]). 24 26 25 27 -define(ESC, 16#1b). ··· 417 419 end. 418 420 419 421 %% --------------------------------------------------------------------------- 420 - %% Tab: filename completion (path under cursor) 422 + %% Tab: command + filename completion (token under cursor) 421 423 %% --------------------------------------------------------------------------- 422 424 %% 423 - %% Completes the token before the cursor as a filesystem path. 424 - %% One match → insert it (directories get a trailing /). 425 + %% Command position (start of line / after | ; & =): complete builtins and 426 + %% PATH executables. Path-like command words (./foo, /bin/ls, ~/x) still use 427 + %% filename completion. Elsewhere: filename completion as before. 428 + %% 429 + %% One match → insert it (commands get a trailing space; dirs get /). 425 430 %% Several matches → extend the longest common prefix; if that does not 426 431 %% advance the buffer, list candidates under the line and redraw. 427 432 428 433 tab_complete(Prompt, Left, Right, History, HistPos, Saved) -> 429 434 {PrefixRev, Word} = word_before_cursor(Left), 430 - case filename_completions(Word) of 435 + {Matches, Kind} = completions_for(PrefixRev, Word), 436 + case Matches of 431 437 [] -> 432 438 beep(), 433 439 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 434 440 [Only] -> 435 - NewLeft = apply_completed_word(PrefixRev, Only), 441 + Insert = finalize_completion(Only, Kind), 442 + NewLeft = apply_completed_word(PrefixRev, Insert), 436 443 redraw(Prompt, NewLeft, Right), 437 444 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 438 - Matches -> 445 + _ -> 439 446 Common = longest_common_prefix(Matches), 440 447 case Common =/= Word andalso length(Common) >= length(Word) of 441 448 true -> ··· 449 456 end 450 457 end. 451 458 459 + %% Test/helper: return {Matches, Kind} for a buffer prefix and word. 460 + %% Prefix is the text *before* the word being completed (not reversed). 461 + %% Kind is <<"command">> | <<"path">>. 462 + -spec complete_word(binary(), binary()) -> {list(binary()), binary()}. 463 + complete_word(PrefixBin, WordBin) when is_binary(PrefixBin), is_binary(WordBin) -> 464 + Prefix = unicode:characters_to_list(PrefixBin), 465 + Word = unicode:characters_to_list(WordBin), 466 + PrefixRev = lists:reverse(Prefix), 467 + {Matches, Kind} = completions_for(PrefixRev, Word), 468 + KindBin = 469 + case Kind of 470 + command -> <<"command">>; 471 + path -> <<"path">> 472 + end, 473 + { 474 + [unicode:characters_to_binary(M) || M <- Matches], 475 + KindBin 476 + }. 477 + 478 + %% Trailing space after a unique command so the user can type args next. 479 + finalize_completion(Word, command) -> 480 + case lists:last(Word) of 481 + $/ -> Word; 482 + $\s -> Word; 483 + _ -> Word ++ " " 484 + end; 485 + finalize_completion(Word, path) -> 486 + Word. 487 + 488 + completions_for(PrefixRev, Word) -> 489 + case is_command_position(PrefixRev) andalso not is_path_like_word(Word) of 490 + true -> 491 + {command_completions(Word), command}; 492 + false -> 493 + {filename_completions(Word), path} 494 + end. 495 + 496 + %% Command position: empty prefix, or last non-space before the word is a 497 + %% pipeline/statement separator or assignment (`let x = …`). 498 + is_command_position(PrefixRev) -> 499 + Before = string:trim(lists:reverse(PrefixRev), trailing), 500 + case Before of 501 + [] -> 502 + true; 503 + _ -> 504 + case lists:last(Before) of 505 + $| -> true; 506 + $; -> true; 507 + $& -> true; 508 + $= -> true; 509 + _ -> false 510 + end 511 + end. 512 + 513 + %% ./script, ../bin/x, /usr/bin/ls, ~/bin/foo — complete as paths even as cmds. 514 + is_path_like_word([]) -> 515 + false; 516 + is_path_like_word(Word) -> 517 + lists:member($/, Word) orelse lists:member($\\, Word) orelse hd(Word) =:= $~. 518 + 519 + %% Builtins + keywords + PATH executables matching Word as a prefix. 520 + command_completions(Word) -> 521 + Builtins = [ 522 + N 523 + || N <- builtin_command_names(), 524 + lists:prefix(Word, N) 525 + ], 526 + Keywords = [ 527 + N 528 + || N <- ["let"], 529 + lists:prefix(Word, N) 530 + ], 531 + PathCmds = 532 + case Word of 533 + %% Empty prefix: skip PATH dump (can be thousands of names). 534 + [] -> 535 + []; 536 + _ -> 537 + path_command_completions(Word) 538 + end, 539 + lists:usort(Builtins ++ Keywords ++ PathCmds). 540 + 541 + %% Prefer live Gleam registry; fall back if the module is not loaded yet. 542 + builtin_command_names() -> 543 + try 544 + Names = 'gleshell@builtins':names(), 545 + [to_charlist(N) || N <- Names] 546 + catch 547 + _:_ -> 548 + fallback_builtin_names() 549 + end. 550 + 551 + to_charlist(B) when is_binary(B) -> 552 + unicode:characters_to_list(B); 553 + to_charlist(L) when is_list(L) -> 554 + L. 555 + 556 + fallback_builtin_names() -> 557 + [ 558 + "append", "cat", "cd", "columns", "count", "describe", "echo", "env", 559 + "exit", "filter", "find", "first", "flatten", "from", "get", "help", 560 + "identity", "ignore", "is-empty", "is_empty", "keys", "last", "length", 561 + "lines", "ls", "open", "prepend", "print", "pwd", "quit", "range", 562 + "reverse", "save", "select", "skip", "sort-by", "sort_by", "sys", 563 + "table", "take", "to", "type", "typeof", "uniq", "unwrap", 564 + "values", "where", "which", "wrap" 565 + ]. 566 + 567 + %% Executable basenames on PATH that match Prefix (deduped, sorted). 568 + path_command_completions(Prefix) -> 569 + case os:getenv("PATH") of 570 + false -> 571 + []; 572 + PathStr -> 573 + Dirs = string:tokens(PathStr, path_sep()), 574 + Acc = lists:foldl( 575 + fun(Dir, Seen) -> 576 + collect_path_cmds(Dir, Prefix, Seen) 577 + end, 578 + #{}, 579 + Dirs 580 + ), 581 + lists:sort(maps:keys(Acc)) 582 + end. 583 + 584 + collect_path_cmds(Dir, Prefix, Seen) -> 585 + case file:list_dir(Dir) of 586 + {ok, Names} -> 587 + lists:foldl( 588 + fun(Name, Acc) -> 589 + case 590 + lists:prefix(Prefix, Name) 591 + andalso show_dotfile(Prefix, Name) 592 + andalso not maps:is_key(Name, Acc) 593 + andalso is_executable_file(filename:join(Dir, Name)) 594 + of 595 + true -> 596 + Acc#{Name => true}; 597 + false -> 598 + Acc 599 + end 600 + end, 601 + Seen, 602 + Names 603 + ); 604 + {error, _} -> 605 + Seen 606 + end. 607 + 452 608 beep() -> 453 609 io:put_chars([7]). 454 610 ··· 1000 1156 os:getenv() 1001 1157 ). 1002 1158 1159 + %% Substring/regex search helper for the `find` builtin. 1160 + %% Returns {ok, true|false} or {error, Message} on invalid pattern. 1161 + -spec re_contains(binary(), binary(), boolean()) -> {ok, boolean()} | {error, binary()}. 1162 + re_contains(Text, Pattern, IgnoreCase) 1163 + when is_binary(Text), is_binary(Pattern), is_boolean(IgnoreCase) -> 1164 + Opts0 = [unicode], 1165 + Opts = 1166 + case IgnoreCase of 1167 + true -> 1168 + [caseless | Opts0]; 1169 + false -> 1170 + Opts0 1171 + end, 1172 + case re:compile(Pattern, Opts) of 1173 + {ok, Re} -> 1174 + case re:run(Text, Re, [{capture, none}]) of 1175 + match -> 1176 + {ok, true}; 1177 + nomatch -> 1178 + {ok, false} 1179 + end; 1180 + {error, {Reason, _}} -> 1181 + {error, iolist_to_binary(io_lib:format("~p", [Reason]))}; 1182 + {error, Reason} -> 1183 + {error, iolist_to_binary(io_lib:format("~p", [Reason]))} 1184 + end. 1185 + 1003 1186 -spec which(binary()) -> {ok, binary()} | {error, nil}. 1004 1187 which(Command) when is_binary(Command) -> 1005 1188 case which_all(Command) of ··· 1107 1290 %% 1. `run_cmd/2` — capture stdout/stderr into a binary (pipelines, `let x =`, 1108 1291 %% non-TTY). Uses pipes; the child does NOT get a real terminal. 1109 1292 %% 1110 - %% 2. `run_cmd_tty/2` — foreground interactive. The child inherits the real 1111 - %% stdio FDs (`nouse_stdio`) so pagers (`less`), editors (`vim`), and most 1112 - %% TUI tools work. BEAM only waits on exit status; keys go straight to the 1113 - %% child (no broken PTY byte-relay). 1293 + %% 2. `run_cmd_tty/2` — foreground interactive. Prefer util-linux `script` 1294 + %% (PTY + key relay via `io:get_chars`) so Ctrl+C can SIGINT the child. 1295 + %% `erl_child_setup` calls setsid, so the child is never in the terminal's 1296 + %% foreground process group — kernel SIGINT goes to BEAM, not the child. 1297 + %% Fallback: inherit real stdio (`nouse_stdio`) when script/TTY is missing. 1114 1298 %% 1115 - %% Auth tools (`sudo`, `run0`, …) need a *controlling* TTY; erl_child_setup 1116 - %% calls setsid, so plain inherit is not enough. For those we wrap with 1117 - %% util-linux `script` to allocate a PTY and relay keys via `io:get_chars` 1118 - %% (same path as the raw line editor — a competing file:read on /dev/pts 1119 - %% never sees keypresses while prim_tty owns the device). 1299 + %% Auth tools (`sudo`, `run0`, …) also need a controlling TTY; the PTY path 1300 + %% covers that. Host termios during children: cooked for OPOST/ONLCR (no 1301 + %% staircase) but ISIG off so Ctrl+C is readable as byte 3 instead of opening 1302 + %% the Erlang BREAK menu. 1120 1303 %% --------------------------------------------------------------------------- 1121 1304 1122 - -spec run_cmd(binary(), [binary()]) -> {ok, {integer(), binary()}} | {error, binary()}. 1123 - run_cmd(Command, Args) when is_binary(Command), is_list(Args) -> 1305 + -spec run_cmd(binary(), [binary()], binary()) -> 1306 + {ok, {integer(), binary()}} | {error, binary()}. 1307 + run_cmd(Command, Args, Stdin) when is_binary(Command), is_list(Args), is_binary(Stdin) -> 1124 1308 case resolve_cmd(Command, Args) of 1125 1309 {error, _} = E -> 1126 1310 E; 1127 1311 {ok, Path, PortArgs} -> 1128 1312 try 1129 - run_cmd_capture(Path, PortArgs) 1313 + run_cmd_capture(Path, PortArgs, Stdin) 1130 1314 catch 1131 1315 _:Reason -> 1132 1316 {error, reason_to_bin(Reason)} ··· 1134 1318 end. 1135 1319 1136 1320 %% Foreground interactive: inherit TTY when possible. 1137 - -spec run_cmd_tty(binary(), [binary()]) -> {ok, {integer(), binary()}} | {error, binary()}. 1138 - run_cmd_tty(Command, Args) when is_binary(Command), is_list(Args) -> 1321 + %% Non-empty Stdin is still fed (temp file + redirect) so `cat f | less` works. 1322 + %% 1323 + %% While the REPL uses OTP `{noshell, raw}`, prim_tty leaves termios with 1324 + %% OPOST/ONLCR off so a bare LF does not return the cursor to column 0. 1325 + %% Tools that write LF-only lines (fastfetch, many TUIs) look staircased if 1326 + %% they inherit that TTY. Wrap inherit/PTY runs in cooked mode and restore 1327 + %% raw afterwards (same idea as println/1 converting to CRLF for shell text). 1328 + -spec run_cmd_tty(binary(), [binary()], binary()) -> 1329 + {ok, {integer(), binary()}} | {error, binary()}. 1330 + run_cmd_tty(Command, Args, Stdin) when is_binary(Command), is_list(Args), is_binary(Stdin) -> 1139 1331 case resolve_cmd(Command, Args) of 1140 1332 {error, _} = E -> 1141 1333 E; ··· 1143 1335 try 1144 1336 case stdout_isatty() of 1145 1337 false -> 1146 - run_cmd_capture(Path, PortArgs); 1338 + run_cmd_capture(Path, PortArgs, Stdin); 1147 1339 true -> 1148 - case {needs_controlling_tty(Path), os:find_executable("script"), find_tty_path()} of 1149 - {true, Script, {ok, Tty}} when is_list(Script) -> 1150 - run_cmd_pty(Script, Path, PortArgs, Tty); 1151 - _ -> 1152 - run_cmd_inherit(Path, PortArgs) 1153 - end 1340 + with_cooked_tty(fun() -> 1341 + %% PTY for all interactive when possible: key relay 1342 + %% sees Ctrl+C and can SIGINT the child process group. 1343 + case {os:find_executable("script"), find_tty_path()} of 1344 + {Script, {ok, Tty}} when is_list(Script) -> 1345 + run_cmd_pty(Script, Path, PortArgs, Tty, Stdin); 1346 + _ -> 1347 + run_cmd_inherit(Path, PortArgs, Stdin) 1348 + end 1349 + end) 1154 1350 end 1155 1351 catch 1156 1352 _:Reason -> ··· 1158 1354 end 1159 1355 end. 1160 1356 1357 + %% Temporarily put the controlling TTY into cooked output + non-canonical 1358 + %% input for an external child, then restore previous termios (raw REPL). 1359 + %% 1360 + %% Applied whenever stdout is a TTY (not only raw REPL): -c under a terminal 1361 + %% still needs -isig/-icanon so Ctrl+C is a readable byte for the interrupt 1362 + %% path. No-op when stty/TTY is unavailable. 1363 + with_cooked_tty(Fun) when is_function(Fun, 0) -> 1364 + case stdout_isatty() of 1365 + false -> 1366 + Fun(); 1367 + true -> 1368 + case stty_save() of 1369 + undefined -> 1370 + %% Still try to apply host flags; restore is best-effort. 1371 + stty_sane(), 1372 + try 1373 + Fun() 1374 + after 1375 + ok 1376 + end; 1377 + Saved -> 1378 + stty_sane(), 1379 + try 1380 + Fun() 1381 + after 1382 + stty_restore(Saved) 1383 + end 1384 + end 1385 + end. 1386 + 1387 + stty_save() -> 1388 + case stty_run(["-g"]) of 1389 + {ok, Out} -> 1390 + case string:trim(Out, both, [$\s, $\t, $\n, $\r]) of 1391 + "" -> 1392 + undefined; 1393 + Settings -> 1394 + %% stty -g is a single token of colon-separated hex flags. 1395 + Settings 1396 + end; 1397 + _ -> 1398 + undefined 1399 + end. 1400 + 1401 + stty_sane() -> 1402 + %% Host TTY while an external runs under the raw REPL: 1403 + %% - sane / opost / onlcr: LF→CRLF so children don't staircase 1404 + %% - -isig: Ctrl+C is byte 3 (not kernel SIGINT → BEAM BREAK menu) 1405 + %% - -icanon min 1 time 0: deliver each byte immediately — with ICANON 1406 + %% left on, Ctrl+C sits in the line buffer until Enter and our key 1407 + %% relay never sees it (external freezes; second ^C looks wedged) 1408 + %% - -echo: host must not echo keys we relay into the child PTY 1409 + _ = stty_run([ 1410 + "sane", 1411 + "-isig", 1412 + "-icanon", 1413 + "min", 1414 + "1", 1415 + "time", 1416 + "0", 1417 + "-echo" 1418 + ]), 1419 + ok. 1420 + 1421 + stty_restore(Settings) when is_list(Settings) -> 1422 + _ = stty_run([Settings]), 1423 + ok; 1424 + stty_restore(_) -> 1425 + ok. 1426 + 1427 + %% Run stty against the real terminal device (not a pipe). Prefer the pts 1428 + %% path from /proc (same as sudo/PTY path), then `/dev/tty`. 1429 + %% 1430 + %% NOTE: do not use filelib:is_file/1 for `/dev/tty` — it is a device node, 1431 + %% so is_file returns false and would skip stty entirely (fastfetch staircase). 1432 + stty_run(Args) when is_list(Args) -> 1433 + case os:find_executable("stty") of 1434 + false -> 1435 + {error, no_stty}; 1436 + Stty when is_list(Stty) -> 1437 + stty_run_on(Stty, Args, stty_devices()) 1438 + end. 1439 + 1440 + stty_devices() -> 1441 + case find_tty_path() of 1442 + {ok, Path} -> 1443 + %% Prefer the concrete pts; /dev/tty is a fallback alias. 1444 + [Path, "/dev/tty"]; 1445 + _ -> 1446 + ["/dev/tty"] 1447 + end. 1448 + 1449 + stty_run_on(_Stty, _Args, []) -> 1450 + {error, no_tty}; 1451 + stty_run_on(Stty, Args, [Dev | Rest]) -> 1452 + case stty_on_device(Stty, Dev, Args) of 1453 + {ok, _} = Ok -> 1454 + Ok; 1455 + _ -> 1456 + stty_run_on(Stty, Args, Rest) 1457 + end. 1458 + 1459 + stty_on_device(Stty, Dev, Args) when is_list(Stty), is_list(Dev), is_list(Args) -> 1460 + try 1461 + Port = open_port( 1462 + {spawn_executable, Stty}, 1463 + [ 1464 + binary, 1465 + exit_status, 1466 + use_stdio, 1467 + stderr_to_stdout, 1468 + {args, ["-F", Dev | Args]} 1469 + ] 1470 + ), 1471 + %% No interrupt watch — internal helper, must not steal TTY input. 1472 + case collect_output_quiet(Port, <<>>, 5000) of 1473 + {ok, {0, Bin}} -> 1474 + {ok, unicode:characters_to_list(Bin)}; 1475 + {ok, {Status, Bin}} -> 1476 + {error, {Status, Bin}}; 1477 + {error, _} = E -> 1478 + E 1479 + end 1480 + catch 1481 + _:_ -> 1482 + {error, stty_failed} 1483 + end. 1484 + 1161 1485 resolve_cmd(Command, Args) -> 1162 1486 case os:find_executable(unicode:characters_to_list(Command)) of 1163 1487 false -> ··· 1167 1491 {ok, Path, PortArgs} 1168 1492 end. 1169 1493 1170 - %% Basename check for tools that need a controlling TTY (not just isatty). 1171 - needs_controlling_tty(Path) when is_list(Path) -> 1172 - Base = filename:basename(Path), 1173 - lists:member(Base, ["sudo", "run0", "pkexec", "doas", "su"]). 1174 - 1175 1494 %% Capture mode: pipes, no TTY. `child_env` forces color when the shell wants 1176 1495 %% it so tools like `jj` still embed ANSI we can pass through on display. 1177 1496 %% 1178 - %% Stdin is redirected from /dev/null via `sh -c` so programs that read stdin 1179 - %% (bare `less`, `cat`, `wc`) get EOF immediately instead of hanging on an 1180 - %% open-but-never-written pipe. (Port option `out` alone breaks exit_status 1181 - %% delivery on current OTP.) 1182 - run_cmd_capture(Path, PortArgs) -> 1183 - Sh = 1184 - case os:find_executable("sh") of 1497 + %% Stdin is always redirected via `sh -c` + `$GLESHELL_STDIN` (either a temp 1498 + %% file with pipeline bytes, or `/dev/null`) so programs never hang on an 1499 + %% open-but-never-written Erlang port pipe. 1500 + run_cmd_capture(Path, PortArgs, Stdin) when is_binary(Stdin) -> 1501 + with_stdin_file(Stdin, fun(StdinPath) -> 1502 + sh_exec(Path, PortArgs, StdinPath, capture) 1503 + end). 1504 + 1505 + %% Inherit real stdio — pagers/editors talk to the terminal directly. 1506 + %% LESS=FRX (via child_env) lets less pass ANSI colors from jj/git. 1507 + %% 1508 + %% Empty stdin: pure inherit (bare `less` reads the TTY). 1509 + %% Non-empty stdin: still inherit stdout/stderr TTY, but redirect stdin from 1510 + %% a temp file so `cat file | less` pages the pipeline data. 1511 + %% 1512 + %% Ctrl+C: prefer the PTY path (key relay). Inherit is a fallback when 1513 + %% `script` is missing — host is -isig/-icanon so Ctrl+C is byte 3; a 1514 + %% watcher SIGINTs the child's process group (setsid means kernel SIGINT 1515 + %% never reaches the child even with ISIG on). 1516 + run_cmd_inherit(Path, PortArgs, Stdin) when is_binary(Stdin) -> 1517 + case Stdin of 1518 + <<>> -> 1519 + Port = open_port( 1520 + {spawn_executable, Path}, 1521 + [ 1522 + exit_status, 1523 + nouse_stdio, 1524 + {env, child_env()}, 1525 + {args, PortArgs} 1526 + ] 1527 + ), 1528 + put(gleshell_output_shown, true), 1529 + await_port_exit_interruptible(Port); 1530 + _ -> 1531 + with_stdin_file(Stdin, fun(StdinPath) -> 1532 + sh_exec(Path, PortArgs, StdinPath, inherit) 1533 + end) 1534 + end. 1535 + 1536 + %% PTY + key relay (interactive TTY, sudo/run0, …). 1537 + %% 1538 + %% util-linux `script` does NOT exec the argv after `--` directly. It runs 1539 + %% `$SHELL -c "<joined args>"` (see script(1)). Nested 1540 + %% `sh -c 'exec "$0" …' path` therefore becomes one mangled shell string and 1541 + %% the real binary never runs (fastfetch → empty output, exit 0). 1542 + %% 1543 + %% Empty stdin: pass Path/args through as simple tokens (`script -- cmd args`). 1544 + %% Non-empty stdin (pipeline → less): write a one-shot runner script that 1545 + %% redirects and execs, then `script -- /tmp/runner` (single path token). 1546 + run_cmd_pty(Script, Path, PortArgs, TtyPath, <<>>) -> 1547 + run_cmd_pty_argv(Script, [Path | PortArgs], TtyPath, Path, PortArgs, <<>>); 1548 + run_cmd_pty(Script, Path, PortArgs, TtyPath, Stdin) when is_binary(Stdin) -> 1549 + with_stdin_file(Stdin, fun(StdinPath) -> 1550 + with_exec_runner(Path, PortArgs, StdinPath, fun(Runner) -> 1551 + run_cmd_pty_argv(Script, [Runner], TtyPath, Path, PortArgs, Stdin) 1552 + end) 1553 + end). 1554 + 1555 + run_cmd_pty_argv(Script, Argv, TtyPath, Path, PortArgs, Stdin) when is_list(Argv) -> 1556 + with_trapped_exits(fun() -> 1557 + Port = open_port( 1558 + {spawn_executable, Script}, 1559 + [ 1560 + binary, 1561 + exit_status, 1562 + stderr_to_stdout, 1563 + use_stdio, 1564 + stream, 1565 + {env, child_env()}, 1566 + {args, ["-q", "-e", "/dev/null", "--" | Argv]} 1567 + ] 1568 + ), 1569 + case file:open(TtyPath, [write, raw, binary]) of 1570 + {ok, TtyOut} -> 1571 + GL = group_leader(), 1572 + Reader = spawn(fun() -> 1573 + group_leader(GL, self()), 1574 + io_to_port(Port) 1575 + end), 1576 + put(gleshell_output_shown, true), 1577 + try 1578 + collect_output_relay(Port, TtyOut, <<>>) 1579 + after 1580 + exit(Reader, kill), 1581 + catch file:close(TtyOut), 1582 + catch port_close(Port) 1583 + end; 1584 + {error, _} -> 1585 + catch port_close(Port), 1586 + put(gleshell_output_shown, false), 1587 + run_cmd_inherit(Path, PortArgs, Stdin) 1588 + end 1589 + end). 1590 + 1591 + %% One-shot `#!/bin/sh` runner: exec Path with PortArgs, stdin from StdinPath. 1592 + %% Needed because `script` flattens argv into `$SHELL -c` (no real multi-arg exec). 1593 + with_exec_runner(Path, PortArgs, StdinPath, Fun) when is_function(Fun, 1) -> 1594 + case write_runner_script(Path, PortArgs, StdinPath) of 1595 + {ok, Runner} -> 1596 + try 1597 + Fun(Runner) 1598 + after 1599 + _ = file:delete(Runner) 1600 + end; 1601 + {error, Reason} -> 1602 + {error, reason_to_bin({runner_script, Reason})} 1603 + end. 1604 + 1605 + write_runner_script(Path, PortArgs, StdinPath) -> 1606 + Dir = 1607 + case os:getenv("TMPDIR") of 1185 1608 false -> 1186 - "/bin/sh"; 1187 - S -> 1188 - S 1609 + "/tmp"; 1610 + "" -> 1611 + "/tmp"; 1612 + D -> 1613 + D 1189 1614 end, 1190 - %% sh -c 'exec "$0" "$@" < /dev/null' path arg1 arg2 ... 1191 - %% $0 = Path; "$@" = remaining args — no shell-quoting of user args. 1615 + Name = 1616 + filename:join( 1617 + Dir, 1618 + "gleshell-run-" ++ integer_to_list(erlang:unique_integer([positive])) 1619 + ), 1620 + Body = runner_script_body(Path, PortArgs, StdinPath), 1621 + case file:write_file(Name, Body) of 1622 + ok -> 1623 + case file:change_mode(Name, 8#755) of 1624 + ok -> 1625 + {ok, Name}; 1626 + {error, _} = E -> 1627 + _ = file:delete(Name), 1628 + E 1629 + end; 1630 + {error, _} = E -> 1631 + E 1632 + end. 1633 + 1634 + runner_script_body(Path, PortArgs, StdinPath) -> 1635 + ArgsQ = [[$\s, shell_single_quote(A)] || A <- PortArgs], 1636 + [ 1637 + "#!/bin/sh\n", 1638 + "exec ", 1639 + shell_single_quote(Path), 1640 + ArgsQ, 1641 + " < ", 1642 + shell_single_quote(StdinPath), 1643 + "\n" 1644 + ]. 1645 + 1646 + %% Safe single-quoted shell token (`foo'bar` → `'foo'\''bar'`). 1647 + shell_single_quote(S) when is_list(S) -> 1648 + [$' | shell_single_quote_chars(S) ++ "'"]; 1649 + shell_single_quote(B) when is_binary(B) -> 1650 + shell_single_quote(unicode:characters_to_list(B)). 1651 + 1652 + shell_single_quote_chars([]) -> 1653 + []; 1654 + shell_single_quote_chars([$' | Rest]) -> 1655 + "'\\''" ++ shell_single_quote_chars(Rest); 1656 + shell_single_quote_chars([C | Rest]) -> 1657 + [C | shell_single_quote_chars(Rest)]. 1658 + 1659 + find_sh() -> 1660 + case os:find_executable("sh") of 1661 + false -> 1662 + "/bin/sh"; 1663 + S -> 1664 + S 1665 + end. 1666 + 1667 + %% Run Path with stdin redirected from StdinPath. 1668 + %% Mode `capture` uses pipes; `inherit` uses nouse_stdio (real TTY for out/err). 1669 + sh_exec(Path, PortArgs, StdinPath, capture) -> 1192 1670 Port = open_port( 1193 - {spawn_executable, Sh}, 1671 + {spawn_executable, find_sh()}, 1194 1672 [ 1195 1673 binary, 1196 1674 exit_status, 1197 1675 stderr_to_stdout, 1198 1676 use_stdio, 1199 1677 stream, 1200 - {env, child_env()}, 1201 - {args, ["-c", "exec \"$0\" \"$@\" < /dev/null", Path | PortArgs]} 1678 + {env, [{"GLESHELL_STDIN", StdinPath} | child_env()]}, 1679 + {args, ["-c", "exec \"$0\" \"$@\" < \"$GLESHELL_STDIN\"", Path | PortArgs]} 1202 1680 ] 1203 1681 ), 1204 1682 put(gleshell_output_shown, false), 1205 - collect_output(Port, <<>>). 1206 - 1207 - %% Inherit real stdio — pagers/editors talk to the terminal directly. 1208 - %% LESS=FRX (via child_env) lets less pass ANSI colors from jj/git. 1209 - run_cmd_inherit(Path, PortArgs) -> 1683 + collect_output(Port, <<>>); 1684 + sh_exec(Path, PortArgs, StdinPath, inherit) -> 1210 1685 Port = open_port( 1211 - {spawn_executable, Path}, 1686 + {spawn_executable, find_sh()}, 1212 1687 [ 1213 1688 exit_status, 1214 1689 nouse_stdio, 1215 - {env, child_env()}, 1216 - {args, PortArgs} 1690 + {env, [{"GLESHELL_STDIN", StdinPath} | child_env()]}, 1691 + {args, ["-c", "exec \"$0\" \"$@\" < \"$GLESHELL_STDIN\"", Path | PortArgs]} 1217 1692 ] 1218 1693 ), 1219 1694 put(gleshell_output_shown, true), 1220 - %% No timeout: less/vim/top may run for a long time. 1221 - receive 1222 - {Port, {exit_status, Status}} -> 1223 - {ok, {Status, <<>>}} 1224 - end. 1695 + await_port_exit_interruptible(Port). 1225 1696 1226 - %% Controlling-TTY + key relay for sudo/run0/etc. 1227 - run_cmd_pty(Script, Path, PortArgs, TtyPath) -> 1228 - Port = open_port( 1229 - {spawn_executable, Script}, 1230 - [ 1231 - binary, 1232 - exit_status, 1233 - stderr_to_stdout, 1234 - use_stdio, 1235 - stream, 1236 - {env, child_env()}, 1237 - {args, ["-q", "-e", "/dev/null", "--", Path | PortArgs]} 1238 - ] 1239 - ), 1240 - case file:open(TtyPath, [write, raw, binary]) of 1241 - {ok, TtyOut} -> 1242 - GL = group_leader(), 1243 - Reader = spawn(fun() -> 1244 - group_leader(GL, self()), 1245 - io_to_port(Port) 1246 - end), 1247 - put(gleshell_output_shown, true), 1697 + %% Provide a filesystem path for stdin bytes; clean up temp files afterwards. 1698 + with_stdin_file(<<>>, Fun) when is_function(Fun, 1) -> 1699 + Fun("/dev/null"); 1700 + with_stdin_file(Data, Fun) when is_binary(Data), is_function(Fun, 1) -> 1701 + case write_stdin_tmp(Data) of 1702 + {ok, Path} -> 1248 1703 try 1249 - collect_output_relay(Port, TtyOut, <<>>) 1704 + Fun(Path) 1250 1705 after 1251 - exit(Reader, kill), 1252 - catch file:close(TtyOut) 1706 + _ = file:delete(Path) 1253 1707 end; 1254 - {error, _} -> 1255 - put(gleshell_output_shown, false), 1256 - %% Fall back to inherit rather than a silent capture hang. 1257 - run_cmd_inherit(Path, PortArgs) 1708 + {error, Reason} -> 1709 + {error, reason_to_bin({stdin_tmp, Reason})} 1710 + end. 1711 + 1712 + write_stdin_tmp(Data) when is_binary(Data) -> 1713 + Dir = 1714 + case os:getenv("TMPDIR") of 1715 + false -> 1716 + "/tmp"; 1717 + "" -> 1718 + "/tmp"; 1719 + D -> 1720 + D 1721 + end, 1722 + Name = 1723 + filename:join( 1724 + Dir, 1725 + "gleshell-stdin-" ++ integer_to_list(erlang:unique_integer([positive])) 1726 + ), 1727 + case file:write_file(Name, Data) of 1728 + ok -> 1729 + {ok, Name}; 1730 + {error, _} = E -> 1731 + E 1258 1732 end. 1259 1733 1260 1734 %% Relay keypresses from the group leader to the child's PTY (script stdin). 1735 + %% Ctrl+C (ETX / byte 3): SIGINT the child process group, and still write the 1736 + %% byte so the PTY line discipline can deliver SIGINT on the slave side too. 1261 1737 io_to_port(Port) -> 1262 1738 case io:get_chars("", 1) of 1263 1739 eof -> ··· 1268 1744 case io_data_to_bin(Data) of 1269 1745 <<>> -> 1270 1746 io_to_port(Port); 1747 + <<3>> = Bin -> 1748 + signal_port_group(Port, int), 1749 + catch port_command(Port, Bin), 1750 + io_to_port(Port); 1271 1751 Bin -> 1272 1752 catch port_command(Port, Bin), 1273 1753 io_to_port(Port) ··· 1288 1768 io_data_to_bin(_) -> 1289 1769 <<>>. 1290 1770 1771 + %% When a port's OS process is killed (Ctrl+C → SIGINT), the linked port may 1772 + %% exit with `epipe` / signal reasons. Without trap_exit the shell process 1773 + %% dies with "Erlang exit: Epipe" instead of returning to the prompt. 1774 + with_trapped_exits(Fun) when is_function(Fun, 0) -> 1775 + Old = process_flag(trap_exit, true), 1776 + try 1777 + Fun() 1778 + after 1779 + process_flag(trap_exit, Old), 1780 + drain_exit_msgs() 1781 + end. 1782 + 1783 + drain_exit_msgs() -> 1784 + receive 1785 + {'EXIT', _, _} -> 1786 + drain_exit_msgs() 1787 + after 0 -> 1788 + ok 1789 + end. 1790 + 1791 + %% Inherit path: watch for Ctrl+C (byte 3) and SIGINT the child group. 1792 + %% Used when `script`/PTY is unavailable; same kill path as the PTY relay. 1793 + await_port_exit_interruptible(Port) -> 1794 + with_trapped_exits(fun() -> 1795 + with_interrupt_watch(Port, fun() -> 1796 + await_port_exit_interruptible_loop(Port) 1797 + end) 1798 + end). 1799 + 1800 + await_port_exit_interruptible_loop(Port) -> 1801 + receive 1802 + {Port, {exit_status, Status}} -> 1803 + {ok, {Status, <<>>}}; 1804 + {'EXIT', Port, _Reason} -> 1805 + {ok, {130, <<>>}}; 1806 + {gleshell_interrupt, _} -> 1807 + signal_port_group(Port, int), 1808 + await_port_exit_after_interrupt(Port, 2000) 1809 + end. 1810 + 1811 + await_port_exit_after_interrupt(Port, GraceMs) -> 1812 + receive 1813 + {Port, {exit_status, Status}} -> 1814 + {ok, {Status, <<>>}}; 1815 + {'EXIT', Port, _Reason} -> 1816 + {ok, {130, <<>>}}; 1817 + {gleshell_interrupt, _} -> 1818 + signal_port_group(Port, kill), 1819 + await_port_exit_after_interrupt(Port, 1000) 1820 + after GraceMs -> 1821 + signal_port_group(Port, kill), 1822 + catch port_close(Port), 1823 + receive 1824 + {Port, {exit_status, Status}} -> 1825 + {ok, {Status, <<>>}}; 1826 + {'EXIT', Port, _} -> 1827 + {ok, {130, <<>>}} 1828 + after 1000 -> 1829 + {ok, {130, <<>>}} 1830 + end 1831 + end. 1832 + 1291 1833 collect_output(Port, Acc) -> 1834 + with_trapped_exits(fun() -> 1835 + with_interrupt_watch(Port, fun() -> 1836 + collect_output_loop(Port, Acc, 120_000) 1837 + end) 1838 + end). 1839 + 1840 + collect_output_loop(Port, Acc, Timeout) -> 1292 1841 receive 1293 1842 {Port, {data, Data}} when is_binary(Data) -> 1294 - collect_output(Port, <<Acc/binary, Data/binary>>); 1843 + collect_output_loop(Port, <<Acc/binary, Data/binary>>, Timeout); 1295 1844 {Port, {data, Data}} when is_list(Data) -> 1296 1845 Bin = unicode:characters_to_binary(Data), 1297 - collect_output(Port, <<Acc/binary, Bin/binary>>); 1846 + collect_output_loop(Port, <<Acc/binary, Bin/binary>>, Timeout); 1298 1847 {Port, {exit_status, Status}} -> 1299 - {ok, {Status, Acc}} 1300 - after 120_000 -> 1848 + {ok, {Status, Acc}}; 1849 + {'EXIT', Port, _Reason} -> 1850 + {ok, {130, Acc}}; 1851 + {gleshell_interrupt, _} -> 1852 + signal_port_group(Port, int), 1853 + collect_output_after_interrupt(Port, Acc, 2000) 1854 + after Timeout -> 1855 + signal_port_group(Port, term), 1301 1856 catch port_close(Port), 1302 1857 {error, <<"command timed out after 120s">>} 1303 1858 end. 1304 1859 1305 - %% PTY auth session: no timeout (password prompts, etc.). 1860 + collect_output_after_interrupt(Port, Acc, GraceMs) -> 1861 + receive 1862 + {Port, {data, Data}} when is_binary(Data) -> 1863 + collect_output_after_interrupt( 1864 + Port, <<Acc/binary, Data/binary>>, GraceMs 1865 + ); 1866 + {Port, {data, Data}} when is_list(Data) -> 1867 + Bin = unicode:characters_to_binary(Data), 1868 + collect_output_after_interrupt( 1869 + Port, <<Acc/binary, Bin/binary>>, GraceMs 1870 + ); 1871 + {Port, {exit_status, Status}} -> 1872 + {ok, {Status, Acc}}; 1873 + {'EXIT', Port, _Reason} -> 1874 + {ok, {130, Acc}}; 1875 + {gleshell_interrupt, _} -> 1876 + signal_port_group(Port, kill), 1877 + collect_output_after_interrupt(Port, Acc, 1000) 1878 + after GraceMs -> 1879 + signal_port_group(Port, kill), 1880 + catch port_close(Port), 1881 + receive 1882 + {Port, {data, Data}} when is_binary(Data) -> 1883 + collect_output_after_interrupt( 1884 + Port, <<Acc/binary, Data/binary>>, 500 1885 + ); 1886 + {Port, {data, Data}} when is_list(Data) -> 1887 + Bin = unicode:characters_to_binary(Data), 1888 + collect_output_after_interrupt( 1889 + Port, <<Acc/binary, Bin/binary>>, 500 1890 + ); 1891 + {Port, {exit_status, Status}} -> 1892 + {ok, {Status, Acc}}; 1893 + {'EXIT', Port, _} -> 1894 + {ok, {130, Acc}} 1895 + after 1000 -> 1896 + {ok, {130, Acc}} 1897 + end 1898 + end. 1899 + 1900 + %% PTY session: no timeout (password prompts, long pagers, etc.). 1901 + %% Ctrl+C is handled in io_to_port/1 (SIGINT); also accept interrupt msgs. 1306 1902 collect_output_relay(Port, Tty, Acc) -> 1307 1903 receive 1308 1904 {Port, {data, Data}} when is_binary(Data) -> ··· 1313 1909 _ = file:write(Tty, Bin), 1314 1910 collect_output_relay(Port, Tty, <<Acc/binary, Bin/binary>>); 1315 1911 {Port, {exit_status, Status}} -> 1316 - {ok, {Status, normalize_pty_output(Acc)}} 1912 + {ok, {Status, normalize_pty_output(Acc)}}; 1913 + {gleshell_interrupt, _} -> 1914 + signal_port_group(Port, int), 1915 + collect_output_relay_after_interrupt(Port, Tty, Acc, 2000) 1916 + end. 1917 + 1918 + collect_output_relay_after_interrupt(Port, Tty, Acc, GraceMs) -> 1919 + receive 1920 + {Port, {data, Data}} when is_binary(Data) -> 1921 + _ = file:write(Tty, Data), 1922 + collect_output_relay_after_interrupt( 1923 + Port, Tty, <<Acc/binary, Data/binary>>, GraceMs 1924 + ); 1925 + {Port, {data, Data}} when is_list(Data) -> 1926 + Bin = unicode:characters_to_binary(Data), 1927 + _ = file:write(Tty, Bin), 1928 + collect_output_relay_after_interrupt( 1929 + Port, Tty, <<Acc/binary, Bin/binary>>, GraceMs 1930 + ); 1931 + {Port, {exit_status, Status}} -> 1932 + {ok, {Status, normalize_pty_output(Acc)}}; 1933 + {gleshell_interrupt, _} -> 1934 + signal_port_group(Port, kill), 1935 + collect_output_relay_after_interrupt(Port, Tty, Acc, 1000) 1936 + after GraceMs -> 1937 + signal_port_group(Port, kill), 1938 + catch port_close(Port), 1939 + receive 1940 + {Port, {exit_status, Status}} -> 1941 + {ok, {Status, normalize_pty_output(Acc)}} 1942 + after 1000 -> 1943 + {ok, {130, normalize_pty_output(Acc)}} 1944 + end 1945 + end. 1946 + 1947 + %% --------------------------------------------------------------------------- 1948 + %% Ctrl+C / SIGINT forwarding 1949 + %% 1950 + %% BEAM's open_port → erl_child_setup → setsid, so the child is not in the 1951 + %% terminal foreground group. Host ISIG is left off while a child runs; we 1952 + %% watch for byte 3 (ETX) and kill(-pid, SIGINT) on the child's process group. 1953 + %% --------------------------------------------------------------------------- 1954 + 1955 + with_interrupt_watch(_Port, Fun) when is_function(Fun, 0) -> 1956 + Parent = self(), 1957 + GL = group_leader(), 1958 + Watcher = 1959 + case can_watch_interrupt() of 1960 + true -> 1961 + spawn(fun() -> 1962 + group_leader(GL, self()), 1963 + interrupt_watch_loop(Parent) 1964 + end); 1965 + false -> 1966 + undefined 1967 + end, 1968 + try 1969 + Fun() 1970 + after 1971 + case Watcher of 1972 + undefined -> 1973 + ok; 1974 + W -> 1975 + exit(W, kill), 1976 + drain_interrupt_msgs() 1977 + end 1978 + end. 1979 + 1980 + %% Collect port output without Ctrl+C watching (stty and other helpers). 1981 + collect_output_quiet(Port, Acc, Timeout) -> 1982 + receive 1983 + {Port, {data, Data}} when is_binary(Data) -> 1984 + collect_output_quiet(Port, <<Acc/binary, Data/binary>>, Timeout); 1985 + {Port, {data, Data}} when is_list(Data) -> 1986 + Bin = unicode:characters_to_binary(Data), 1987 + collect_output_quiet(Port, <<Acc/binary, Bin/binary>>, Timeout); 1988 + {Port, {exit_status, Status}} -> 1989 + {ok, {Status, Acc}} 1990 + after Timeout -> 1991 + catch port_close(Port), 1992 + {error, <<"command timed out">>} 1993 + end. 1994 + 1995 + %% Watch when the REPL owns the TTY (raw mode) or stdin is a terminal. 1996 + can_watch_interrupt() -> 1997 + case get(gleshell_raw) of 1998 + true -> 1999 + true; 2000 + _ -> 2001 + stdout_isatty() 2002 + end. 2003 + 2004 + interrupt_watch_loop(Parent) when is_pid(Parent) -> 2005 + case catch io:get_chars("", 1) of 2006 + eof -> 2007 + ok; 2008 + {error, _} -> 2009 + ok; 2010 + {'EXIT', _} -> 2011 + ok; 2012 + Data -> 2013 + case io_data_to_bin(Data) of 2014 + <<3>> -> 2015 + Parent ! {gleshell_interrupt, self()}, 2016 + interrupt_watch_loop(Parent); 2017 + _ -> 2018 + %% Non-Ctrl+C: discard here (capture mode). Interactive 2019 + %% PTY uses io_to_port instead; inherit prefers PTY. 2020 + interrupt_watch_loop(Parent) 2021 + end 2022 + end. 2023 + 2024 + drain_interrupt_msgs() -> 2025 + receive 2026 + {gleshell_interrupt, _} -> 2027 + drain_interrupt_msgs() 2028 + after 0 -> 2029 + ok 2030 + end. 2031 + 2032 + %% SIGINT/SIGTERM/SIGKILL the port's OS process group (setsid → pgid = pid). 2033 + signal_port_group(Port, Sig) when is_port(Port) -> 2034 + case erlang:port_info(Port, os_pid) of 2035 + {os_pid, Pid} when is_integer(Pid), Pid > 0 -> 2036 + kill_os_group(Pid, Sig); 2037 + _ -> 2038 + ok 2039 + end. 2040 + 2041 + kill_os_group(Pid, Sig) when is_integer(Pid) -> 2042 + Kill = 2043 + case os:find_executable("kill") of 2044 + false -> 2045 + "kill"; 2046 + K -> 2047 + K 2048 + end, 2049 + SigArg = 2050 + case Sig of 2051 + int -> 2052 + "-INT"; 2053 + term -> 2054 + "-TERM"; 2055 + kill -> 2056 + "-KILL" 2057 + end, 2058 + %% Negative pid → process group (child is session/group leader after setsid). 2059 + Pg = "-" ++ integer_to_list(Pid), 2060 + Single = integer_to_list(Pid), 2061 + _ = kill_once(Kill, [SigArg, Pg]), 2062 + _ = kill_once(Kill, [SigArg, Single]), 2063 + ok. 2064 + 2065 + kill_once(Kill, Args) -> 2066 + try 2067 + Port = open_port( 2068 + {spawn_executable, Kill}, 2069 + [exit_status, nouse_stdio, {args, Args}] 2070 + ), 2071 + receive 2072 + {Port, {exit_status, _}} -> 2073 + ok 2074 + after 1000 -> 2075 + catch port_close(Port), 2076 + ok 2077 + end 2078 + catch 2079 + _:_ -> 2080 + ok 1317 2081 end. 1318 2082 1319 2083 %% PTY line discipline often emits CR-LF; normalize to LF for structured use.
+218 -3
test/gleshell_test.gleam
··· 1 + import gleam/list 1 2 import gleam/string 2 3 import gleeunit 4 + import gleshell/builtins 3 5 import gleshell/color 4 6 import gleshell/display 5 7 import gleshell/env ··· 136 138 Nil 137 139 } 138 140 141 + /// Pipeline input must become the external's stdin (`cat f | less`, `echo hi | wc`). 142 + /// Use `let` so the last stage is capture mode even on a TTY (bare expressions 143 + /// may inherit the terminal and return an empty string value). 144 + pub fn eval_pipeline_stdin_to_external_test() { 145 + let env = env.new() 146 + // `echo` is a builtin; `wc` is external. Count bytes of "hello" (no trailing NL). 147 + let assert eval.Continue(_, String(out)) = 148 + eval.eval_source(env, "let n = echo hello | ^wc -c") 149 + let assert True = string.contains(out, "5") 150 + // Tight `cmd|cmd` form (no spaces around pipe) 151 + let assert eval.Continue(_, String(out2)) = 152 + eval.eval_source(env, "let m = echo ab|^wc -c") 153 + let assert True = string.contains(out2, "2") 154 + Nil 155 + } 156 + 139 157 pub fn eval_let_and_var_test() { 140 158 let env = env.new() 141 159 let assert eval.Continue(env2, Int(7)) = ··· 213 231 Nil 214 232 } 215 233 234 + pub fn help_covers_all_builtins_test() { 235 + // Every registered builtin must have a dedicated help_text entry. 236 + let assert [] = builtins.missing_help() 237 + 238 + let env = env.new() 239 + // which finds builtins; help must too (not "unknown command") 240 + let assert eval.Continue(_, String(which_out)) = 241 + eval.eval_source(env, "which table") 242 + let assert "builtin: table" = which_out 243 + let assert eval.Continue(_, String(help_out)) = 244 + eval.eval_source(env, "help table") 245 + let assert True = string.contains(help_out, "table") 246 + let assert True = string.contains(help_out, "coerce") 247 + 248 + // Parent commands with subcommands: `help to` / `help from` must resolve. 249 + let assert eval.Continue(_, String(to_help)) = 250 + eval.eval_source(env, "help to") 251 + let assert True = string.contains(to_help, "json") 252 + let assert eval.Continue(_, String(from_help)) = 253 + eval.eval_source(env, "help from") 254 + let assert True = string.contains(from_help, "json") 255 + 256 + // Bare help lists every command with its one-line description. 257 + let assert eval.Continue(_, String(all)) = eval.eval_source(env, "help") 258 + list.each(builtins.names(), fn(name) { 259 + let assert True = string.contains(all, name) 260 + }) 261 + 262 + let assert eval.Continue(env2, value.Fail(msg)) = 263 + eval.eval_source(env, "help not-a-real-cmd") 264 + let assert True = string.contains(msg, "unknown command") 265 + let assert 1 = env2.last_exit 266 + Nil 267 + } 268 + 216 269 pub fn eval_from_json_test() { 217 270 let env = env.new() 218 271 let assert eval.Continue(_, Record(fields)) = ··· 223 276 224 277 pub fn eval_to_json_pretty_test() { 225 278 let env = env.new() 226 - // Nushell-style multi-word `to json` — pretty by default 279 + // `to` command + `json` subcommand — pretty by default 227 280 let assert eval.Continue(_, String(pretty)) = 228 281 eval.eval_source(env, "echo [1 2 3] | to json") 229 282 let assert True = string.contains(pretty, "\n") ··· 232 285 let assert eval.Continue(_, String(raw)) = 233 286 eval.eval_source(env, "echo [1 2 3] | to json --raw") 234 287 let assert "[1,2,3]" = raw 288 + // Missing / unknown subcommand 289 + let assert eval.Continue(env2, value.Fail(msg)) = 290 + eval.eval_source(env, "echo 1 | to") 291 + let assert True = string.contains(msg, "subcommand") 292 + let assert 1 = env2.last_exit 293 + let assert eval.Continue(_, value.Fail(msg2)) = 294 + eval.eval_source(env, "echo 1 | to yaml") 295 + let assert True = string.contains(msg2, "unknown subcommand") 235 296 Nil 236 297 } 237 298 ··· 273 334 Nil 274 335 } 275 336 337 + pub fn eval_find_list_test() { 338 + let env = env.new() 339 + // Substring match (OR across terms) 340 + let assert eval.Continue(_, List(items)) = 341 + eval.eval_source(env, "echo [moe larry curly] | find l") 342 + let assert [String("larry"), String("curly")] = items 343 + // Multiple terms 344 + let assert eval.Continue(_, List(items2)) = 345 + eval.eval_source(env, "echo [a.toml b.md c.rs] | find toml md") 346 + let assert [String("a.toml"), String("b.md")] = items2 347 + // Exact number match 348 + let assert eval.Continue(_, List([Int(5)])) = 349 + eval.eval_source(env, "echo [1 5 3 4 35] | find 5") 350 + Nil 351 + } 352 + 353 + pub fn eval_find_ignore_case_invert_test() { 354 + let env = env.new() 355 + let assert eval.Continue(_, List(items)) = 356 + eval.eval_source(env, "echo [Hello world HELLO] | find hello -i") 357 + let assert [String("Hello"), String("HELLO")] = items 358 + // `-i term` may attach term to the flag; still works 359 + let assert eval.Continue(_, List(items2)) = 360 + eval.eval_source(env, "echo [Hello world HELLO] | find -i hello") 361 + let assert [String("Hello"), String("HELLO")] = items2 362 + let assert eval.Continue(_, List([String("cd")])) = 363 + eval.eval_source(env, "echo [ab cd] | find --invert a") 364 + Nil 365 + } 366 + 367 + pub fn eval_find_table_and_string_test() { 368 + let env = env.new() 369 + let assert eval.Continue(_, Table(["name", "type"], rows)) = 370 + eval.eval_source( 371 + env, 372 + "echo [{name: a, type: file} {name: b, type: dir}] | table | find file", 373 + ) 374 + let assert [[String("a"), String("file")]] = rows 375 + // Single-line string: return the string if match, else nothing 376 + let assert eval.Continue(_, String("Cargo.toml")) = 377 + eval.eval_source(env, "echo Cargo.toml | find Cargo") 378 + let assert eval.Continue(_, Nothing) = 379 + eval.eval_source(env, "echo Cargo.toml | find zz") 380 + // Multi-line string → list of matching lines 381 + let assert eval.Continue(_, List(lines)) = 382 + eval.eval_source(env, "echo \"hi\nbye\nhi there\" | find hi") 383 + let assert [String("hi"), String("hi there")] = lines 384 + Nil 385 + } 386 + 387 + pub fn eval_find_regex_test() { 388 + let env = env.new() 389 + let assert eval.Continue(_, List(items)) = 390 + eval.eval_source(env, "echo [abc odb arc abf] | find --regex \"b.\"") 391 + let assert [String("abc"), String("abf")] = items 392 + // Regex + terms is rejected (Nu-compatible) 393 + let assert eval.Continue(env2, value.Fail(msg)) = 394 + eval.eval_source(env, "echo [abc] | find --regex \"b.\" x") 395 + let assert True = string.contains(msg, "regex") 396 + let assert 1 = env2.last_exit 397 + Nil 398 + } 399 + 276 400 pub fn eval_which_all_test() { 277 401 let env = env.new() 278 402 // `-a` includes the builtin first, then any PATH copies of `ls`. ··· 345 469 Nil 346 470 } 347 471 472 + pub fn format_filesize_units_test() { 473 + let assert "0 B" = display.format_filesize(0) 474 + let assert "512 B" = display.format_filesize(512) 475 + let assert "1023 B" = display.format_filesize(1023) 476 + let assert "1 KB" = display.format_filesize(1024) 477 + let assert "1.5 KB" = display.format_filesize(1536) 478 + let assert "1 MB" = display.format_filesize(1_048_576) 479 + let assert "1.5 MB" = display.format_filesize(1_048_576 + 524_288) 480 + let assert "1 GB" = display.format_filesize(1_073_741_824) 481 + Nil 482 + } 483 + 484 + pub fn display_size_column_humanized_test() { 485 + // Data stays as raw bytes (Int); only display shows KB/MB. 486 + let text = 487 + display.render_with( 488 + False, 489 + Table(["name", "size"], [[String("a"), Int(2048)]]), 490 + ) 491 + let assert True = string_contains(text, "2 KB") 492 + let assert False = string_contains(text, "2048") 493 + Nil 494 + } 495 + 348 496 pub fn color_visible_length_strips_ansi_test() { 349 497 let painted = color.paint(True, "\u{001b}[32m", "hi") 350 498 let assert 2 = color.visible_length(painted) ··· 399 547 Nil 400 548 } 401 549 402 - pub fn highlight_to_json_multiword_test() { 550 + pub fn highlight_to_command_test() { 551 + // `to` is a builtin; `json` is a subcommand arg 403 552 let text = highlight.highlight(True, "range 3 | to json") 404 - let assert True = string_contains(text, "to json") 553 + let assert True = string_contains(text, "to") 554 + let assert True = string_contains(text, "json") 405 555 let assert True = string_contains(text, "\u{001b}[1;36m") 406 556 Nil 407 557 } ··· 422 572 _ -> True 423 573 } 424 574 } 575 + 576 + // --- tab completion --- 577 + 578 + pub fn complete_command_builtin_test() { 579 + // Start of line: complete builtins 580 + let #(matches, kind) = sys.complete_word("", "ech") 581 + let assert True = kind == "command" 582 + let assert True = list_contains(matches, "echo") 583 + // After pipeline separator 584 + let #(matches2, kind2) = sys.complete_word("ls | ", "wher") 585 + let assert True = kind2 == "command" 586 + let assert True = list_contains(matches2, "where") 587 + // `to` / `from` are single-word commands (subcommand is a normal arg) 588 + let #(matches3, kind3) = sys.complete_word("", "to") 589 + let assert True = kind3 == "command" 590 + let assert True = list_contains(matches3, "to") 591 + let #(matches_from, kind_from) = sys.complete_word("", "fro") 592 + let assert True = kind_from == "command" 593 + let assert True = list_contains(matches_from, "from") 594 + // Keyword 595 + let #(matches4, kind4) = sys.complete_word("", "le") 596 + let assert True = kind4 == "command" 597 + let assert True = list_contains(matches4, "let") 598 + Nil 599 + } 600 + 601 + pub fn complete_command_after_assign_test() { 602 + let #(matches, kind) = sys.complete_word("let x = ", "ran") 603 + let assert True = kind == "command" 604 + let assert True = list_contains(matches, "range") 605 + Nil 606 + } 607 + 608 + pub fn complete_path_for_args_test() { 609 + // After a command name, Tab completes files not commands 610 + let #(_matches, kind) = sys.complete_word("echo ", "ech") 611 + let assert True = kind == "path" 612 + Nil 613 + } 614 + 615 + pub fn complete_path_like_command_test() { 616 + // Path-shaped command words stay on filename completion 617 + let #(_matches, kind) = sys.complete_word("", "./ec") 618 + let assert True = kind == "path" 619 + let #(_matches2, kind2) = sys.complete_word("", "/us") 620 + let assert True = kind2 == "path" 621 + Nil 622 + } 623 + 624 + pub fn complete_path_executable_test() { 625 + // PATH + builtins for a non-empty prefix 626 + let #(matches, kind) = sys.complete_word("", "s") 627 + let assert True = kind == "command" 628 + // At least builtins starting with s (select, skip, sort-by, …) 629 + let assert True = list_contains(matches, "select") 630 + Nil 631 + } 632 + 633 + fn list_contains(items: List(String), needle: String) -> Bool { 634 + case items { 635 + [] -> False 636 + [x, ..] if x == needle -> True 637 + [_, ..rest] -> list_contains(rest, needle) 638 + } 639 + }