diff --git a/.gitignore b/.gitignore index 07d284b..094b0b3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ *.ez /build erl_crash.dump +# gleam export escript writes here +/gleshell # Nix / devenv /result diff --git a/README.md b/README.md index d98c489..a6e1335 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ A **structured-data shell** written in [Gleam](https://gleam.run), inspired by [ 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. ```text -gleshell:gleshell> ls | where type == file | select name size | first 5 +~/code/gleshell on  main +❯ ls | where type == file | select name size | first 5 ╭──────┬──────╮ │ name │ size │ ├──────┼──────┤ @@ -13,23 +14,28 @@ gleshell:gleshell> ls | where type == file | select name size | first 5 ╰──────┴──────╯ ``` +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. + ## Quick start Requires [Gleam](https://gleam.run/getting-started/) and Erlang (or use Nix): ```bash -# with Nix flake (dev shell: gleam, erlang, rebar3) +# install a self-contained `gle` (Erlang shipment in the Nix store — no repo checkout) +nix profile install . +gle -c 'ls | first 3' + +# one-shot without installing +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) nix develop --no-pure-eval gleam run # interactive REPL gleam run -- -c 'ls | first 3' gleam test - -# or one-shot from the repo root -nix run . -- -c 'ls | first 3' - -# or if gleam is already on PATH -gleam run ``` ### REPL editing @@ -41,13 +47,13 @@ green, numbers purple, pipes purple, flags blue, variables purple, …). | Key | Action | |-----|--------| | ↑ / ↓ | History | -| **Tab** | Filename completion (common prefix; list matches if ambiguous) | +| **Tab** | Command completion (builtins + `PATH`) at the start of a pipeline stage; filename completion for arguments (common prefix; list matches if ambiguous) | | **Ctrl+R** | Reverse-i-search through history (Enter accepts onto the line) | | Ctrl+A / Ctrl+E | Beginning / end of line | | Ctrl+W | Delete previous word | | Ctrl+U / Ctrl+K | Kill to start / end of line | | Ctrl+L | Clear screen | -| **Ctrl+C** | Cancel current line (does not exit the shell) | +| **Ctrl+C** | Cancel current line at the prompt; while an external command runs, send SIGINT to that process (does not exit the shell) | | Ctrl+D | EOF (empty line) or delete under cursor | History is persisted under the user cache as `gleshell-history/lines`. @@ -58,6 +64,8 @@ Non-TTY input falls back to Erlang’s `edlin`/`get_until` path. ```nu # list files as a table, filter, project columns ls | where type == file | select name size +ls | find toml md +echo [moe larry curly] | find l # ranges and list ops range 10 | reverse | first 3 @@ -106,9 +114,9 @@ which -a ls Filesystem: `ls`, `cd`, `pwd`, `cat`, `open`, `save` -Table/list: `where`/`filter`, `select`, `get`, `first`, `last`, `take`, `skip`, `sort-by`, `reverse`, `length`, `columns`, `table`, `flatten`, `uniq`, `wrap`, `unwrap`, `keys`, `values` +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 json`, `from json`, `type`, `describe`, `env`, `sys`, `which`, `help`, `exit` +Data: `echo`, `range`, `lines`, `to`/`from` (subcommand `json`), `type`, `describe`, `env`, `sys`, `which`, `help`, `exit` Unknown command names fall through to external executables on `PATH`. @@ -131,10 +139,12 @@ 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`, …) 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. Paginated output gets -`LESS=FRX` when unset so `less` passes ANSI through. +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. Disable with `NO_COLOR=1`; force with `FORCE_COLOR=1`. ## Status diff --git a/flake.nix b/flake.nix index c681cf4..ac4c472 100644 --- a/flake.nix +++ b/flake.nix @@ -31,30 +31,110 @@ system: let pkgs = nixpkgs.legacyPackages.${system}; - runtimeInputs = [ - pkgs.gleam - pkgs.beamPackages.erlang - pkgs.rebar3 - ]; - # Thin wrapper: needs the gleshell source tree (cwd or GLESHELL_ROOT). - gleshell = pkgs.writeShellApplication { - name = "gleshell"; - inherit runtimeInputs; - text = '' - root="''${GLESHELL_ROOT:-}" - if [ -z "$root" ]; then - if [ -f gleam.toml ] && grep -q 'name = "gleshell"' gleam.toml 2>/dev/null; then - root="$PWD" - else - echo "gleshell: run from the gleshell repo root, or set GLESHELL_ROOT" >&2 - exit 1 - fi - fi - cd "$root" - # +Bc: Ctrl+C cancels the line; do not open the Erlang BREAK/abort menu. - export ERL_AFLAGS="+Bc ''${ERL_AFLAGS:-}" - exec gleam run -- "$@" + inherit (pkgs) lib; + + gleamToml = lib.importTOML ./gleam.toml; + manifestToml = lib.importTOML ./manifest.toml; + + hexPackages = builtins.filter (p: p.source == "hex") manifestToml.packages; + + packagesTOML = lib.concatStringsSep "\n" ( + [ "[packages]" ] ++ map (p: "${p.name} = \"${p.version}\"") hexPackages + ); + + # Self-contained store package: compiles to Erlang shipment, wraps with + # store-path Erlang. No source checkout / GLESHELL_ROOT required at runtime. + gleshell = pkgs.stdenv.mkDerivation { + pname = "gleshell"; + version = gleamToml.version; + + src = lib.cleanSourceWith { + src = ./.; + filter = + path: type: + let + base = baseNameOf path; + in + lib.cleanSourceFilter path type + && base != "build" + && base != "result" + && base != ".devenv" + && base != ".direnv" + && base != "erl_crash.dump" + # Root escript is a regular file; keep the src/gleshell/ directory. + && !(type == "regular" && base == "gleshell") + && !(lib.hasSuffix ".escript" base); + }; + + nativeBuildInputs = [ + pkgs.gleam + pkgs.beamPackages.erlang + pkgs.rebar3 + pkgs.beamPackages.hex + pkgs.rsync + ]; + + configurePhase = '' + runHook preConfigure + + mkdir -p build/packages + cat < build/packages/packages.toml + ${packagesTOML} + EOF + + # Vendor Hex deps so the pure Nix build never hits the network. + ${lib.concatMapStringsSep "\n" (p: '' + rsync --chmod=Du=rwx,Dg=rx,Do=rx,Fu=rw,Fg=r,Fo=r -r ${ + pkgs.fetchHex { + pkg = p.name; + version = p.version; + sha256 = p.outer_checksum; + } + }/* build/packages/${p.name}/ + '') hexPackages} + + runHook postConfigure ''; + + buildPhase = '' + runHook preBuild + export REBAR_CACHE_DIR="$TMP/.rebar-cache" + gleam export erlang-shipment + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/{bin,lib/gleshell} + rsync --exclude=entrypoint.sh --exclude=entrypoint.ps1 \ + -r build/erlang-shipment/* $out/lib/gleshell/ + + # Bake -pa paths at install time (each ebin needs its own -pa). + ebin_args=() + for d in $out/lib/gleshell/*/ebin; do + ebin_args+=(-pa "$d") + done + + # Runtime wrapper: fixed store paths, works from any cwd. + # +Bc: Ctrl+C cancels the line; do not open the Erlang BREAK menu. + { + echo "#!${pkgs.runtimeShell}" + echo "set -eu" + echo 'export ERL_AFLAGS="+Bc ''${ERL_AFLAGS:-}"' + echo "exec ${pkgs.beamPackages.erlang}/bin/erl ''${ebin_args[*]} -eval \"gleshell@@main:run(gleshell)\" -noshell -extra \"\$@\"" + } > $out/bin/gle + chmod +x $out/bin/gle + ln -s gle $out/bin/gleshell + + runHook postInstall + ''; + + meta = { + description = gleamToml.description; + mainProgram = "gle"; + license = lib.licenses.asl20; + }; }; in { @@ -66,7 +146,7 @@ apps = forEachSystem (system: { default = { type = "app"; - program = "${self.packages.${system}.gleshell}/bin/gleshell"; + program = "${self.packages.${system}.gleshell}/bin/gle"; }; }); diff --git a/src/gleshell.gleam b/src/gleshell.gleam index a004763..0390da3 100644 --- a/src/gleshell.gleam +++ b/src/gleshell.gleam @@ -1,6 +1,7 @@ //// gleshell — a structured-data shell in Gleam, inspired by Nushell. import argv +import filepath import gleam/io import gleam/string import gleshell/color @@ -9,6 +10,7 @@ import gleshell/env import gleshell/eval import gleshell/sys import gleshell/value.{Nothing} +import simplifile pub fn main() -> Nil { case argv.load().arguments { @@ -63,7 +65,7 @@ fn run_once(code: String) -> Nil { fn repl(env: env.Env) -> Nil { sys.println( - "gleshell 0.1 — structured data shell (type `help`, `exit` to quit; Tab completes paths, Ctrl+R search)", + "gleshell 0.1 — structured data shell (type `help`, `exit` to quit; Tab completes commands/paths, Ctrl+R search)", ) repl_loop(env) } @@ -106,39 +108,143 @@ fn repl_loop(env: env.Env) -> Nil { } } +/// Zero-config prompt inspired by Starship: +/// blank line, directory (+ optional git branch), then a green/red `❯`. +/// +/// Status lines are printed once; only the character is the line-editor prompt +/// (the raw editor redraws a single line). fn prompt_for(env: env.Env) -> String { - let base = basename(env.cwd) let on = color.enabled() - color.prompt_name(on, "gleshell") - <> color.separator(on, ":") - <> color.prompt_path(on, base) - <> color.prompt_mark(on, "> ") + let path = color.prompt_path(on, display_cwd(env.cwd)) + let git = case git_branch(env.cwd) { + Ok(branch) -> + color.separator(on, " on ") + <> color.prompt_git(on, " " <> branch) + Error(Nil) -> "" + } + sys.println("") + sys.println(path <> git) + prompt_character(env.last_exit) +} + +fn prompt_character(last_exit: Int) -> String { + let on = color.enabled() + let mark = case last_exit { + 0 -> color.prompt_character_ok(on, "❯") + _ -> color.prompt_character_err(on, "❯") + } + mark <> " " +} + +/// Full cwd for the prompt, with `$HOME` shown as `~`. +fn display_cwd(cwd: String) -> String { + case sys.home_dir() { + Ok(home) -> abbreviate_home(cwd, home) + Error(_) -> cwd + } +} + +fn abbreviate_home(path: String, home: String) -> String { + case path == home { + True -> "~" + False -> + case string.starts_with(path, home <> "/") { + // Keep the leading `/` after home so `~/code` not `~code`. + True -> "~" <> string.drop_start(path, string.length(home)) + False -> path + } + } } -fn basename(path: String) -> String { - case string.split(path, "/") { - [] -> path - parts -> - case list_last(parts) { - "" -> - // path ended with / - case parts { - [only] -> only +/// Best-effort branch name by reading `.git` (no `git` process). +fn git_branch(cwd: String) -> Result(String, Nil) { + case find_git_dir(cwd, 32) { + Error(Nil) -> Error(Nil) + Ok(git_dir) -> read_git_head_branch(git_dir) + } +} + +fn find_git_dir(dir: String, budget: Int) -> Result(String, Nil) { + case budget <= 0 { + True -> Error(Nil) + False -> { + let candidate = filepath.join(dir, ".git") + case simplifile.is_directory(candidate) { + Ok(True) -> Ok(candidate) + _ -> + case simplifile.is_file(candidate) { + Ok(True) -> read_gitdir_pointer(candidate) _ -> { - // take second last non-empty if possible - path + let parent = filepath.directory_name(dir) + case parent == "" || parent == dir { + True -> Error(Nil) + False -> find_git_dir(parent, budget - 1) + } } } - name -> name } + } + } +} + +/// Worktree / linked checkout: `.git` is a file `gitdir: `. +fn read_gitdir_pointer(git_file: String) -> Result(String, Nil) { + case simplifile.read(git_file) { + Error(_) -> Error(Nil) + Ok(body) -> { + let line = string.trim(first_line(body)) + case string.starts_with(line, "gitdir:") { + False -> Error(Nil) + True -> { + let raw = string.trim(string.drop_start(line, 7)) + case raw { + "" -> Error(Nil) + path -> + case filepath.is_absolute(path) { + True -> Ok(path) + False -> + Ok(filepath.join(filepath.directory_name(git_file), path)) + } + } + } + } + } + } +} + +fn read_git_head_branch(git_dir: String) -> Result(String, Nil) { + case simplifile.read(filepath.join(git_dir, "HEAD")) { + Error(_) -> Error(Nil) + Ok(body) -> { + let line = string.trim(first_line(body)) + case string.starts_with(line, "ref: ") { + True -> { + let ref = string.trim(string.drop_start(line, 5)) + // Prefer short branch name: refs/heads/main → main + case string.starts_with(ref, "refs/heads/") { + True -> Ok(string.drop_start(ref, 11)) + False -> Ok(filepath.base_name(ref)) + } + } + // Detached HEAD — show a short SHA when it looks like one. + False -> + case string.length(line) >= 7 { + True -> Ok(string.slice(line, 0, 7)) + False -> + case line { + "" -> Error(Nil) + other -> Ok(other) + } + } + } + } } } -fn list_last(items: List(String)) -> String { - case items { - [] -> "" - [x] -> x - [_, ..rest] -> list_last(rest) +fn first_line(s: String) -> String { + case string.split_once(s, "\n") { + Ok(#(line, _)) -> line + Error(Nil) -> s } } diff --git a/src/gleshell/builtins.gleam b/src/gleshell/builtins.gleam index 58f0c79..4b603ae 100644 --- a/src/gleshell/builtins.gleam +++ b/src/gleshell/builtins.gleam @@ -38,6 +38,7 @@ pub fn registry() -> dict.Dict(String, Builtin) { #("save", cmd_save), #("where", cmd_where), #("filter", cmd_where), + #("find", cmd_find), #("select", cmd_select), #("get", cmd_get), #("first", cmd_first), @@ -52,9 +53,9 @@ pub fn registry() -> dict.Dict(String, Builtin) { #("uniq", cmd_uniq), #("wrap", cmd_wrap), #("unwrap", cmd_unwrap), - // Nushell multi-word: `to json` / `from json` - #("to json", cmd_to_json), - #("from json", cmd_from_json), + // Nushell-style: `to` / `from` with format subcommands (`json`) + #("to", cmd_to), + #("from", cmd_from), #("lines", cmd_lines), #("typeof", cmd_type), #("type", cmd_type), @@ -85,6 +86,11 @@ pub fn names() -> List(String) { |> list.sort(string.compare) } +/// Registered builtins that lack a dedicated `help_text` entry (should be empty). +pub fn missing_help() -> List(String) { + list.filter(names(), fn(n) { !dict.has_key(help_text(), n) }) +} + fn ok(env: Env, value: Value) -> BuiltinResult { BuiltinResult(env, value) } @@ -103,11 +109,18 @@ fn cmd_help( ) -> BuiltinResult { case args { [String(name)] -> - case dict.get(help_text(), name) { + case help_for(name) { Ok(text) -> ok(env, String(text)) Error(Nil) -> err(env, "unknown command: " <> name) } _ -> { + let command_lines = + list.map(names(), fn(n) { + case help_line(n) { + Ok(text) -> " " <> text + Error(Nil) -> " " <> n + } + }) let lines = list.append( [ @@ -120,7 +133,7 @@ fn cmd_help( "", "Commands:", ], - list.append(list.map(names(), fn(n) { " " <> n }), [ + list.append(command_lines, [ "", "Use `help ` for details. `^cmd` forces an external binary.", "Variables: `let x = ...` then `$x`. Pipeline input is `$in`.", @@ -132,11 +145,87 @@ fn cmd_help( } } +/// One-line help for a registered builtin, or Error if the name is not a builtin. +fn help_line(name: String) -> Result(String, Nil) { + case dict.get(help_text(), name) { + Ok(text) -> Ok(text) + Error(Nil) -> + case dict.has_key(registry(), name) { + // Safety net if help_text drifts; `missing_help` / tests should catch this. + True -> Ok(name <> " — builtin command") + False -> Error(Nil) + } + } +} + +/// Full help text for `help `, including subcommands where relevant. +fn help_for(name: String) -> Result(String, Nil) { + case name { + "to" -> + Ok(string.join( + [ + "to — convert pipeline input to a text format", + "", + "Subcommands:", + " json [--raw|-r] [--indent|-i n] — JSON string (pretty by default;", + " --raw is compact, no trailing newline)", + "", + "Examples:", + " range 3 | to json", + " ls | to json --raw", + ], + "\n", + )) + "from" -> + Ok(string.join( + [ + "from — parse text input into structured data", + "", + "Subcommands:", + " json — parse a JSON string (pipeline input or a string argument)", + "", + "Examples:", + " open data.json | from json", + " echo '{\"a\": 1}' | from json | get a", + ], + "\n", + )) + "find" -> + Ok(string.join( + [ + "find [-i] [-v] [--regex pat] [--columns cols] … — search pipeline input", + "", + "Filters lists/tables for items matching any term (OR). Strings use", + "substring match; numbers/bools match by equality. Multi-line strings", + "are split into lines (unless --multiline).", + "", + "Flags:", + " -i, --ignore-case case-insensitive match", + " -v, --invert keep non-matching items", + " -r, --regex Erlang regex (not combined with terms)", + " -c, --columns only search these table columns", + " -m, --multiline do not split multi-line strings into lines", + "", + "Examples:", + " ls | find toml md", + " echo [moe larry curly] | find l", + " echo [Hello world] | find hello -i", + " echo [abc odb abf] | find --regex \"b.\"", + ], + "\n", + )) + _ -> help_line(name) + } +} + fn help_text() -> dict.Dict(String, String) { dict.from_list([ + #("help", "help [command] — list builtins, or show help for one command"), + #("echo", "echo … — emit values (list if multiple)"), + #("print", "print … — alias for echo"), #("ls", "ls [path] — list directory entries as a table"), - #("cd", "cd [path] — change directory (~ supported)"), #("pwd", "pwd — print working directory"), + #("cd", "cd [path] — change directory (~ supported)"), #("cat", "cat — read file as string"), #("open", "open — open file; parses .json into structured data"), #("save", "save — save pipeline input to a file"), @@ -144,31 +233,66 @@ fn help_text() -> dict.Dict(String, String) { "where", "where — filter rows (ops: == != > < >= <=)", ), + #("filter", "filter — alias for where"), + #( + "find", + "find [-i] [-v] [--regex pat] … — search list/table/string input for terms", + ), #("select", "select … — keep only named columns"), #("get", "get — get a field or list index"), #("first", "first [n] — first row/item (default 1)"), #("last", "last [n] — last row/item"), - #("take", "take — take first n rows"), - #("skip", "skip — skip first n rows"), - #("echo", "echo … — emit values (list if multiple)"), + #("take", "take — take first n items"), + #("skip", "skip — skip first n items"), + #("length", "length — number of items in list/table/string input"), + #("count", "count — alias for length"), + #("reverse", "reverse — reverse list, table rows, or string graphemes"), + #("sort-by", "sort-by — sort table rows by field"), + #("sort_by", "sort_by — alias for sort-by"), + #("uniq", "uniq — drop duplicate list items (order preserved)"), + #("wrap", "wrap — wrap pipeline input as a single-field record"), + #("unwrap", "unwrap [name] — unwrap a record field (default: first field)"), #( - "to json", - "to json [--raw|-r] [--indent|-i n] — convert input to JSON string (pretty by default)", + "to", + "to — convert pipeline input (subcommands: json)", ), #( - "from json", - "from json — parse JSON string input into structured data", + "from", + "from — parse structured input (subcommands: json)", + ), + #("lines", "lines — split string input into a list of lines"), + #("typeof", "typeof — type name of pipeline input"), + #("type", "type — alias for typeof"), + #( + "describe", + "describe — record with type, length, and string form of input", ), - #("range", "range | range — integer range list"), #( "env", "env [NAME] — process environment table, or one var (same as `$env` / `$env.NAME`)", ), - #("sys", "sys — host info record"), #( "which", "which [-a|--all] — path of command (builtin or on PATH); -a lists all matches", ), + #("exit", "exit [code] — leave the shell (default code 0)"), + #("quit", "quit [code] — alias for exit"), + #("ignore", "ignore — discard pipeline input; emit nothing"), + #("identity", "identity — pass pipeline input through unchanged"), + #("range", "range | range — integer range list"), + #("append", "append … — append values to list input"), + #("prepend", "prepend … — prepend values to list input"), + #("is-empty", "is-empty — true if list/table/string input has length 0"), + #("is_empty", "is_empty — alias for is-empty"), + #( + "table", + "table — coerce list of records (or table) into a table", + ), + #("columns", "columns — column names of a table, or keys of a record"), + #("flatten", "flatten — one level of list-of-lists flattening"), + #("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)"), ]) } @@ -413,6 +537,314 @@ fn row_matches(row: Value, field: String, op: String, rhs: Value) -> Bool { } } +// --- find (Nushell-style search filter) --- + +fn cmd_find( + env: Env, + input: Value, + args: List(Value), + flags: dict.Dict(String, Value), +) -> BuiltinResult { + // Boolean flags may steal the next word (`find -i hello` → flag i = "hello"). + let #(ignore_case, stolen_i) = + find_bool_flag(flags, ["i", "ignore-case"]) + let #(invert, stolen_v) = find_bool_flag(flags, ["v", "invert"]) + let #(multiline, stolen_m) = find_bool_flag(flags, ["m", "multiline"]) + let #(_, stolen_n) = find_bool_flag(flags, ["n", "no-highlight"]) + let #(_, stolen_s) = find_bool_flag(flags, ["s", "dotall"]) + let #(_, stolen_rfind) = find_bool_flag(flags, ["R", "rfind"]) + + let regex_opt = find_regex_pattern(flags) + let columns = find_columns(flags) + + let terms = + list.flatten([ + args, + stolen_i, + stolen_v, + stolen_m, + stolen_n, + stolen_s, + stolen_rfind, + ]) + + case terms, regex_opt { + [], option.None -> + err(env, "find: expected search term(s) or --regex ") + _, option.Some(Error(msg)) -> err(env, "find: " <> msg) + [_, ..], option.Some(Ok(_)) -> + err(env, "find: cannot use --regex with additional search terms") + terms, regex_opt -> { + let pattern = case regex_opt { + option.Some(Ok(p)) -> option.Some(p) + _ -> option.None + } + case + find_filter( + input, + terms, + pattern, + ignore_case, + invert, + multiline, + columns, + ) + { + Ok(v) -> ok(env, v) + Error(msg) -> err(env, "find: " <> msg) + } + } + } +} + +/// Parse a boolean flag that may have stolen a following value as its arg. +/// Returns `(flag_set, stolen_terms)`. +fn find_bool_flag( + flags: dict.Dict(String, Value), + names: List(String), +) -> #(Bool, List(Value)) { + list.fold(names, #(False, []), fn(acc, name) { + let #(_set, stolen) = acc + case dict.get(flags, name) { + Error(Nil) -> acc + Ok(Bool(False)) | Ok(Nothing) -> acc + Ok(Bool(True)) -> #(True, stolen) + Ok(v) -> #(True, list.append(stolen, [v])) + } + }) +} + +fn find_regex_pattern( + flags: dict.Dict(String, Value), +) -> option.Option(Result(String, String)) { + case dict.get(flags, "regex"), dict.get(flags, "r") { + Ok(String(p)), _ -> option.Some(Ok(p)) + _, Ok(String(p)) -> option.Some(Ok(p)) + Ok(Bool(True)), _ | _, Ok(Bool(True)) -> + option.Some(Error("regex flag requires a pattern (try `find --regex `)")) + Ok(other), _ -> + option.Some(Ok(value.as_string(other))) + _, Ok(other) -> option.Some(Ok(value.as_string(other))) + Error(Nil), Error(Nil) -> option.None + } +} + +fn find_columns(flags: dict.Dict(String, Value)) -> option.Option(List(String)) { + case dict.get(flags, "columns"), dict.get(flags, "c") { + Ok(v), _ -> columns_from_value(v) + _, Ok(v) -> columns_from_value(v) + Error(Nil), Error(Nil) -> option.None + } +} + +fn columns_from_value(v: Value) -> option.Option(List(String)) { + case v { + List(items) -> { + let cols = + list.filter_map(items, fn(item) { + case item { + String(s) -> Ok(s) + other -> Ok(value.as_string(other)) + } + }) + option.Some(cols) + } + String(s) -> option.Some([s]) + _ -> option.Some([value.as_string(v)]) + } +} + +fn find_filter( + input: Value, + terms: List(Value), + regex: option.Option(String), + ignore_case: Bool, + invert: Bool, + multiline: Bool, + columns: option.Option(List(String)), +) -> Result(Value, String) { + case input { + List(items) -> { + case filter_items(items, terms, regex, ignore_case, invert, columns) { + Ok(kept) -> Ok(List(kept)) + Error(e) -> Error(e) + } + } + Table(_, _) -> + case value.table_to_records(input) { + Error(e) -> Error(e) + Ok(rows) -> + case filter_items(rows, terms, regex, ignore_case, invert, columns) { + Ok(kept) -> Ok(value.table_from_records(kept)) + Error(e) -> Error(e) + } + } + String(s) -> + case multiline || !string.contains(s, "\n") { + True -> + case item_matches(String(s), terms, regex, ignore_case, option.None) { + Error(e) -> Error(e) + Ok(matched) -> + case matched != invert { + True -> Ok(String(s)) + False -> Ok(Nothing) + } + } + False -> { + let lines = list.map(string.split(s, "\n"), String) + case filter_items(lines, terms, regex, ignore_case, invert, option.None) { + Ok(kept) -> Ok(List(kept)) + Error(e) -> Error(e) + } + } + } + Nothing -> Error("pipeline input is required (try `ls | find term`)") + other -> + // Single scalar / record: keep if it matches, else nothing. + case item_matches(other, terms, regex, ignore_case, columns) { + Error(e) -> Error(e) + Ok(matched) -> + case matched != invert { + True -> Ok(other) + False -> Ok(Nothing) + } + } + } +} + +fn filter_items( + items: List(Value), + terms: List(Value), + regex: option.Option(String), + ignore_case: Bool, + invert: Bool, + columns: option.Option(List(String)), +) -> Result(List(Value), String) { + list.try_fold(items, [], fn(acc, item) { + case item_matches(item, terms, regex, ignore_case, columns) { + Error(e) -> Error(e) + Ok(matched) -> + case matched != invert { + True -> Ok(list.append(acc, [item])) + False -> Ok(acc) + } + } + }) +} + +fn item_matches( + item: Value, + terms: List(Value), + regex: option.Option(String), + ignore_case: Bool, + columns: option.Option(List(String)), +) -> Result(Bool, String) { + case item { + Record(fields) -> { + let fields = case columns { + option.None -> fields + option.Some(cols) -> + list.filter(fields, fn(pair) { + let #(k, _) = pair + list.contains(cols, k) + }) + } + // Match if any selected field matches (OR), or exact record equality. + case list.any(terms, fn(t) { value.equals(item, t) }) { + True -> Ok(True) + False -> + list.try_fold(fields, False, fn(acc, pair) { + case acc { + True -> Ok(True) + False -> { + let #(_, v) = pair + item_matches(v, terms, regex, ignore_case, option.None) + } + } + }) + } + } + List(inner) -> { + // Nested list: match if any element matches, or the rendered text does. + case + list.try_fold(inner, False, fn(acc, v) { + case acc { + True -> Ok(True) + False -> item_matches(v, terms, regex, ignore_case, option.None) + } + }) + { + Error(e) -> Error(e) + Ok(True) -> Ok(True) + Ok(False) -> + text_matches(value.as_string(item), terms, regex, ignore_case) + } + } + // Scalars: exact equality always; strings also allow substring contains. + // Numbers/bools do not substring-match (Nu: `find 5` does not keep 35). + String(s) -> text_matches(s, terms, regex, ignore_case) + Int(_) | Float(_) | Bool(_) -> scalar_matches(item, terms, regex, ignore_case) + Nothing -> scalar_matches(item, terms, regex, ignore_case) + other -> { + case list.any(terms, fn(t) { value.equals(other, t) }) { + True -> Ok(True) + False -> text_matches(value.as_string(other), terms, regex, ignore_case) + } + } + } +} + +fn scalar_matches( + scalar: Value, + terms: List(Value), + regex: option.Option(String), + ignore_case: Bool, +) -> Result(Bool, String) { + case regex { + option.Some(pattern) -> + text_matches( + value.as_string(scalar), + terms, + option.Some(pattern), + ignore_case, + ) + option.None -> Ok(list.any(terms, fn(t) { value.equals(scalar, t) })) + } +} + +fn text_matches( + text: String, + terms: List(Value), + regex: option.Option(String), + ignore_case: Bool, +) -> Result(Bool, String) { + case regex { + option.Some(pattern) -> + case sys.re_contains(text, pattern, ignore_case) { + Ok(b) -> Ok(b) + Error(msg) -> Error("invalid regex: " <> msg) + } + option.None -> { + let haystack = case ignore_case { + True -> string.lowercase(text) + False -> text + } + Ok( + list.any(terms, fn(term) { + let needle = case ignore_case { + True -> string.lowercase(value.as_string(term)) + False -> value.as_string(term) + } + case needle { + "" -> True + _ -> string.contains(haystack, needle) + } + }), + ) + } + } +} + fn cmd_select( env: Env, input: Value, @@ -723,7 +1155,37 @@ fn cmd_unwrap( } } -// --- json (mirrors Nushell `to json` / `from json`) --- +// --- to / from (Nushell-style format commands with subcommands) --- + +fn cmd_to( + env: Env, + input: Value, + args: List(Value), + flags: dict.Dict(String, Value), +) -> BuiltinResult { + case args { + [String("json"), ..rest] -> cmd_to_json(env, input, rest, flags) + [] -> + err(env, "to: expected subcommand (try `to json`; see `help to`)") + [String(sub), ..] -> err(env, "to: unknown subcommand: " <> sub) + _ -> err(env, "to: expected subcommand name") + } +} + +fn cmd_from( + env: Env, + input: Value, + args: List(Value), + flags: dict.Dict(String, Value), +) -> BuiltinResult { + case args { + [String("json"), ..rest] -> cmd_from_json(env, input, rest, flags) + [] -> + err(env, "from: expected subcommand (try `from json`; see `help from`)") + [String(sub), ..] -> err(env, "from: unknown subcommand: " <> sub) + _ -> err(env, "from: expected subcommand name") + } +} fn cmd_to_json( env: Env, @@ -770,11 +1232,11 @@ fn cmd_from_json( _ -> "" } case source { - "" -> err(env, "from json: empty input") + "" -> err(env, "from: json: empty input") s -> case parse_json_value(s) { Ok(v) -> ok(env, v) - Error(msg) -> err(env, "from json: " <> msg) + Error(msg) -> err(env, "from: json: " <> msg) } } } diff --git a/src/gleshell/color.gleam b/src/gleshell/color.gleam index f10b2f7..0161c00 100644 --- a/src/gleshell/color.gleam +++ b/src/gleshell/color.gleam @@ -172,14 +172,30 @@ pub fn prompt_name(on: Bool, text: String) -> String { paint(on, bold_green, text) } +/// Directory segment (bold cyan — Starship-inspired). pub fn prompt_path(on: Bool, text: String) -> String { - paint(on, bright_blue, text) + paint(on, bold_cyan, text) +} + +/// Git branch segment (bold purple — Starship-inspired). +pub fn prompt_git(on: Bool, text: String) -> String { + paint(on, bold_purple, text) } pub fn prompt_mark(on: Bool, text: String) -> String { paint(on, bold, text) } +/// Success character (bold green `❯`). +pub fn prompt_character_ok(on: Bool, text: String) -> String { + paint(on, "\u{001b}[1;32m", text) +} + +/// Error character after non-zero exit (bold red `❯`). +pub fn prompt_character_err(on: Bool, text: String) -> String { + paint(on, bold_red, text) +} + // --- syntax shapes (Nushell `shape_*` defaults) --- pub fn shape_internalcall(on: Bool, text: String) -> String { diff --git a/src/gleshell/display.gleam b/src/gleshell/display.gleam index e037983..474bce0 100644 --- a/src/gleshell/display.gleam +++ b/src/gleshell/display.gleam @@ -97,8 +97,8 @@ fn render_record(on: Bool, fields: List(#(String, Value))) -> String { let #(k, v) = pair let key = color.key(on, pad_right(k, key_w)) let bar = color.separator(on, "│") - let cell = - color_cell_for_column(on, k, v, value.cell_string(v), type_hint) + let plain = cell_plain(k, v) + let cell = color_cell_for_column(on, k, v, plain, type_hint) " " <> key <> " " <> bar <> " " <> cell }) let top = color.separator(on, "╭──── record ───") @@ -120,8 +120,16 @@ fn render_table_with( case columns { [] -> color.nothing(on, "(empty table)") _ -> { + // Column-aware plain text (e.g. size → KB/MB) for widths and coloring. let plain_cells: List(List(String)) = - list.map(rows, fn(row) { list.map(row, value.cell_string) }) + list.map(rows, fn(row) { + list.index_map(columns, fn(col, i) { + case list_at(row, i) { + Ok(v) -> cell_plain(col, v) + Error(Nil) -> "" + } + }) + }) // Widths use visible length so cells with ANSI (from external tools) // do not inflate the table. @@ -246,6 +254,51 @@ fn list_index_of_loop( } } +/// Plain cell text before coloring. Keeps pipeline data as raw ints; only +/// display converts `size` byte counts to KB/MB/…. +fn cell_plain(col: String, value: Value) -> String { + case col, value { + "size", Int(n) -> format_filesize(n) + _, _ -> value.cell_string(value) + } +} + +/// Format a byte count for display: B below 1 KiB, then KB / MB / GB / TB +/// (1024-based). One decimal place when the fractional part is non-zero. +pub fn format_filesize(bytes: Int) -> String { + let n = case bytes < 0 { + True -> 0 + False -> bytes + } + case n < 1024 { + True -> int.to_string(n) <> " B" + False -> + case n < 1_048_576 { + True -> scale_unit(n, 1024, " KB") + False -> + case n < 1_073_741_824 { + True -> scale_unit(n, 1_048_576, " MB") + False -> + case n < 1_099_511_627_776 { + True -> scale_unit(n, 1_073_741_824, " GB") + False -> scale_unit(n, 1_099_511_627_776, " TB") + } + } + } + } +} + +fn scale_unit(n: Int, unit: Int, suffix: String) -> String { + // tenths with half-up rounding: (n * 10 + unit/2) / unit + let tenths = { n * 10 + unit / 2 } / unit + let whole = tenths / 10 + let frac = tenths % 10 + case frac { + 0 -> int.to_string(whole) <> suffix + _ -> int.to_string(whole) <> "." <> int.to_string(frac) <> suffix + } +} + /// Color a table/list/record cell by value type and optional column name. fn color_cell(on: Bool, col: String, value: Value, plain: String) -> String { color_cell_for_column(on, col, value, plain, "") diff --git a/src/gleshell/eval.gleam b/src/gleshell/eval.gleam index d4f8f00..2a1ee22 100644 --- a/src/gleshell/eval.gleam +++ b/src/gleshell/eval.gleam @@ -116,7 +116,7 @@ fn eval_command( True -> case eval_argv(env, args) { Error(msg) -> Continue(env.set_exit(env, 1), Fail(msg)) - Ok(str_args) -> run_external(env, name, str_args, interactive) + Ok(str_args) -> run_external(env, name, str_args, input, interactive) } False -> case eval_args(env, args) { @@ -124,9 +124,9 @@ fn eval_command( Ok(#(pos, flags)) -> { // Builtins produce a new value that was not streamed to the TTY. sys.clear_output_shown() - case resolve_builtin(name, pos) { - Ok(#(builtin, pos2)) -> - case builtin(env, input, pos2, flags) { + case dict.get(builtins.registry(), name) { + Ok(builtin) -> + case builtin(env, input, pos, flags) { builtins.Exit(code) -> Quit(code) builtins.BuiltinResult(env2, value) -> { let env2 = case value { @@ -141,7 +141,7 @@ fn eval_command( case eval_argv(env, args) { Error(msg) -> Continue(env.set_exit(env, 1), Fail(msg)) Ok(str_args) -> - run_external(env, name, str_args, interactive) + run_external(env, name, str_args, input, interactive) } } } @@ -151,26 +151,6 @@ fn eval_command( } } -/// Look up a builtin, including Nushell-style multi-word names (`to json`). -/// When `name` alone is missing, try consuming a following bare string arg. -fn resolve_builtin( - name: String, - pos: List(Value), -) -> Result(#(builtins.Builtin, List(Value)), Nil) { - case dict.get(builtins.registry(), name) { - Ok(builtin) -> Ok(#(builtin, pos)) - Error(Nil) -> - case pos { - [String(sub), ..rest] -> - case dict.get(builtins.registry(), name <> " " <> sub) { - Ok(builtin) -> Ok(#(builtin, rest)) - Error(Nil) -> Error(Nil) - } - _ -> Error(Nil) - } - } -} - fn eval_args( env: Env, args: List(Arg), @@ -261,11 +241,14 @@ fn run_external( env: Env, name: String, str_args: List(String), + input: Value, interactive: Bool, ) -> EvalResult { + // Pipeline input becomes the external's stdin (Unix-style `cmd | less`). + let stdin = stdin_bytes(input) let result = case interactive { - True -> sys.run_cmd_tty(name, str_args) - False -> sys.run_cmd(name, str_args) + True -> sys.run_cmd_tty(name, str_args, stdin) + False -> sys.run_cmd(name, str_args, stdin) } case result { Error(msg) -> Continue(env.set_exit(env, 127), Fail(msg)) @@ -283,3 +266,12 @@ fn run_external( } } } + +/// Bytes fed to an external's stdin from the previous pipeline stage. +fn stdin_bytes(input: Value) -> String { + case input { + Nothing -> "" + String(s) -> s + other -> value.as_string(other) + } +} diff --git a/src/gleshell/highlight.gleam b/src/gleshell/highlight.gleam index 1c0d41c..8b24f1d 100644 --- a/src/gleshell/highlight.gleam +++ b/src/gleshell/highlight.gleam @@ -359,34 +359,6 @@ fn paint_command( after: List(String), builtins: List(String), acc: String, -) -> String { - // Multi-word builtins: `to json`, `from json` - case word { - "to" | "from" -> { - let #(ws, rest1) = take_space(after) - let #(next, rest2) = take_ident(rest1) - case next { - "json" -> { - let full = word <> ws <> next - paint_chars( - rest2, - ExpectArg, - builtins, - acc <> color.shape_internalcall(True, full), - ) - } - _ -> paint_single_command(word, after, builtins, acc) - } - } - _ -> paint_single_command(word, after, builtins, acc) - } -} - -fn paint_single_command( - word: String, - after: List(String), - builtins: List(String), - acc: String, ) -> String { let painted = case list.contains(builtins, word) { True -> color.shape_internalcall(True, word) @@ -402,18 +374,6 @@ fn next_expect(expect: Expect) -> Expect { } } -fn take_space(chars: List(String)) -> #(String, List(String)) { - take_space_loop(chars, "") -} - -fn take_space_loop(chars: List(String), acc: String) -> #(String, List(String)) { - case chars { - [" ", ..rest] -> take_space_loop(rest, acc <> " ") - ["\t", ..rest] -> take_space_loop(rest, acc <> "\t") - _ -> #(acc, chars) - } -} - fn take_ident(chars: List(String)) -> #(String, List(String)) { take_ident_loop(chars, "") } diff --git a/src/gleshell/sys.gleam b/src/gleshell/sys.gleam index f623f8f..b7d455f 100644 --- a/src/gleshell/sys.gleam +++ b/src/gleshell/sys.gleam @@ -30,18 +30,28 @@ pub fn setenv(name: String, value: String) -> Result(Nil, Nil) pub fn list_env() -> List(#(String, String)) /// Run an external command capturing stdout/stderr (pipelines, `let`, non-TTY). +/// `stdin` is fed to the process (empty → `/dev/null`). @external(erlang, "gleshell_ffi", "run_cmd") pub fn run_cmd( command: String, args: List(String), + stdin: String, ) -> Result(#(Int, String), String) -/// Run an external command in the foreground on the real TTY when possible -/// (`less`, `vim`, …). Falls back to capture when stdout is not a terminal. +/// Run an external command in the foreground on a TTY when possible +/// (`less`, `vim`, `bat`, `fastfetch`, …). Prefers a PTY (`script`) so keys — +/// including Ctrl+C → SIGINT — reach the child. Pipeline `stdin` is still fed +/// when non-empty (e.g. `cat file | less`). Falls back to capture when stdout +/// is not a terminal, or to plain inherit if `script` is unavailable. +/// +/// In the raw-mode REPL the controlling TTY is briefly switched to cooked +/// termios (with ISIG off) for the child and restored after, so LF-only output +/// does not staircase and Ctrl+C does not open the Erlang BREAK menu. @external(erlang, "gleshell_ffi", "run_cmd_tty") pub fn run_cmd_tty( command: String, args: List(String), + stdin: String, ) -> Result(#(Int, String), String) /// True if the last external command already streamed its output to the TTY @@ -60,6 +70,20 @@ pub fn which(command: String) -> Result(String, Nil) @external(erlang, "gleshell_ffi", "which_all") pub fn which_all(command: String) -> List(String) +/// True if `text` matches Erlang regex `pattern`. `ignore_case` enables caseless. +/// Error string on invalid pattern. +@external(erlang, "gleshell_ffi", "re_contains") +pub fn re_contains( + text: String, + pattern: String, + ignore_case: Bool, +) -> Result(Bool, String) + +/// Tab-completion candidates for `word` given the text before it on the line. +/// Returns `(matches, kind)` where kind is `"command"` or `"path"`. +@external(erlang, "gleshell_ffi", "complete_word") +pub fn complete_word(prefix: String, word: String) -> #(List(String), String) + @external(erlang, "gleshell_ffi", "home_dir") pub fn home_dir() -> Result(String, String) diff --git a/src/gleshell_ffi.erl b/src/gleshell_ffi.erl index 94edf4a..454b6a3 100644 --- a/src/gleshell_ffi.erl +++ b/src/gleshell_ffi.erl @@ -11,15 +11,17 @@ getenv/1, setenv/2, list_env/0, - run_cmd/2, - run_cmd_tty/2, + run_cmd/3, + run_cmd_tty/3, which/1, which_all/1, home_dir/0, stdout_isatty/0, println/1, take_output_shown/0, - clear_output_shown/0 + clear_output_shown/0, + complete_word/2, + re_contains/3 ]). -define(ESC, 16#1b). @@ -417,25 +419,30 @@ raw_loop(Prompt, Left, Right, History, HistPos, Saved) -> end. %% --------------------------------------------------------------------------- -%% Tab: filename completion (path under cursor) +%% Tab: command + filename completion (token under cursor) %% --------------------------------------------------------------------------- %% -%% Completes the token before the cursor as a filesystem path. -%% One match → insert it (directories get a trailing /). +%% Command position (start of line / after | ; & =): complete builtins and +%% PATH executables. Path-like command words (./foo, /bin/ls, ~/x) still use +%% filename completion. Elsewhere: filename completion as before. +%% +%% One match → insert it (commands get a trailing space; dirs get /). %% Several matches → extend the longest common prefix; if that does not %% advance the buffer, list candidates under the line and redraw. tab_complete(Prompt, Left, Right, History, HistPos, Saved) -> {PrefixRev, Word} = word_before_cursor(Left), - case filename_completions(Word) of + {Matches, Kind} = completions_for(PrefixRev, Word), + case Matches of [] -> beep(), raw_loop(Prompt, Left, Right, History, HistPos, Saved); [Only] -> - NewLeft = apply_completed_word(PrefixRev, Only), + Insert = finalize_completion(Only, Kind), + NewLeft = apply_completed_word(PrefixRev, Insert), redraw(Prompt, NewLeft, Right), raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); - Matches -> + _ -> Common = longest_common_prefix(Matches), case Common =/= Word andalso length(Common) >= length(Word) of true -> @@ -449,6 +456,155 @@ tab_complete(Prompt, Left, Right, History, HistPos, Saved) -> end end. +%% Test/helper: return {Matches, Kind} for a buffer prefix and word. +%% Prefix is the text *before* the word being completed (not reversed). +%% Kind is <<"command">> | <<"path">>. +-spec complete_word(binary(), binary()) -> {list(binary()), binary()}. +complete_word(PrefixBin, WordBin) when is_binary(PrefixBin), is_binary(WordBin) -> + Prefix = unicode:characters_to_list(PrefixBin), + Word = unicode:characters_to_list(WordBin), + PrefixRev = lists:reverse(Prefix), + {Matches, Kind} = completions_for(PrefixRev, Word), + KindBin = + case Kind of + command -> <<"command">>; + path -> <<"path">> + end, + { + [unicode:characters_to_binary(M) || M <- Matches], + KindBin + }. + +%% Trailing space after a unique command so the user can type args next. +finalize_completion(Word, command) -> + case lists:last(Word) of + $/ -> Word; + $\s -> Word; + _ -> Word ++ " " + end; +finalize_completion(Word, path) -> + Word. + +completions_for(PrefixRev, Word) -> + case is_command_position(PrefixRev) andalso not is_path_like_word(Word) of + true -> + {command_completions(Word), command}; + false -> + {filename_completions(Word), path} + end. + +%% Command position: empty prefix, or last non-space before the word is a +%% pipeline/statement separator or assignment (`let x = …`). +is_command_position(PrefixRev) -> + Before = string:trim(lists:reverse(PrefixRev), trailing), + case Before of + [] -> + true; + _ -> + case lists:last(Before) of + $| -> true; + $; -> true; + $& -> true; + $= -> true; + _ -> false + end + end. + +%% ./script, ../bin/x, /usr/bin/ls, ~/bin/foo — complete as paths even as cmds. +is_path_like_word([]) -> + false; +is_path_like_word(Word) -> + lists:member($/, Word) orelse lists:member($\\, Word) orelse hd(Word) =:= $~. + +%% Builtins + keywords + PATH executables matching Word as a prefix. +command_completions(Word) -> + Builtins = [ + N + || N <- builtin_command_names(), + lists:prefix(Word, N) + ], + Keywords = [ + N + || N <- ["let"], + lists:prefix(Word, N) + ], + PathCmds = + case Word of + %% Empty prefix: skip PATH dump (can be thousands of names). + [] -> + []; + _ -> + path_command_completions(Word) + end, + lists:usort(Builtins ++ Keywords ++ PathCmds). + +%% Prefer live Gleam registry; fall back if the module is not loaded yet. +builtin_command_names() -> + try + Names = 'gleshell@builtins':names(), + [to_charlist(N) || N <- Names] + catch + _:_ -> + fallback_builtin_names() + end. + +to_charlist(B) when is_binary(B) -> + unicode:characters_to_list(B); +to_charlist(L) when is_list(L) -> + 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" + ]. + +%% Executable basenames on PATH that match Prefix (deduped, sorted). +path_command_completions(Prefix) -> + case os:getenv("PATH") of + false -> + []; + PathStr -> + Dirs = string:tokens(PathStr, path_sep()), + Acc = lists:foldl( + fun(Dir, Seen) -> + collect_path_cmds(Dir, Prefix, Seen) + end, + #{}, + Dirs + ), + lists:sort(maps:keys(Acc)) + end. + +collect_path_cmds(Dir, Prefix, Seen) -> + case file:list_dir(Dir) of + {ok, Names} -> + lists:foldl( + fun(Name, Acc) -> + case + lists:prefix(Prefix, Name) + andalso show_dotfile(Prefix, Name) + andalso not maps:is_key(Name, Acc) + andalso is_executable_file(filename:join(Dir, Name)) + of + true -> + Acc#{Name => true}; + false -> + Acc + end + end, + Seen, + Names + ); + {error, _} -> + Seen + end. + beep() -> io:put_chars([7]). @@ -1000,6 +1156,33 @@ list_env() -> os:getenv() ). +%% Substring/regex search helper for the `find` builtin. +%% Returns {ok, true|false} or {error, Message} on invalid pattern. +-spec re_contains(binary(), binary(), boolean()) -> {ok, boolean()} | {error, binary()}. +re_contains(Text, Pattern, IgnoreCase) + when is_binary(Text), is_binary(Pattern), is_boolean(IgnoreCase) -> + Opts0 = [unicode], + Opts = + case IgnoreCase of + true -> + [caseless | Opts0]; + false -> + Opts0 + end, + case re:compile(Pattern, Opts) of + {ok, Re} -> + case re:run(Text, Re, [{capture, none}]) of + match -> + {ok, true}; + nomatch -> + {ok, false} + end; + {error, {Reason, _}} -> + {error, iolist_to_binary(io_lib:format("~p", [Reason]))}; + {error, Reason} -> + {error, iolist_to_binary(io_lib:format("~p", [Reason]))} + end. + -spec which(binary()) -> {ok, binary()} | {error, nil}. which(Command) when is_binary(Command) -> case which_all(Command) of @@ -1107,26 +1290,27 @@ stdout_isatty() -> %% 1. `run_cmd/2` — capture stdout/stderr into a binary (pipelines, `let x =`, %% non-TTY). Uses pipes; the child does NOT get a real terminal. %% -%% 2. `run_cmd_tty/2` — foreground interactive. The child inherits the real -%% stdio FDs (`nouse_stdio`) so pagers (`less`), editors (`vim`), and most -%% TUI tools work. BEAM only waits on exit status; keys go straight to the -%% child (no broken PTY byte-relay). +%% 2. `run_cmd_tty/2` — foreground interactive. Prefer util-linux `script` +%% (PTY + key relay via `io:get_chars`) so Ctrl+C can SIGINT the child. +%% `erl_child_setup` calls setsid, so the child is never in the terminal's +%% foreground process group — kernel SIGINT goes to BEAM, not the child. +%% Fallback: inherit real stdio (`nouse_stdio`) when script/TTY is missing. %% -%% Auth tools (`sudo`, `run0`, …) need a *controlling* TTY; erl_child_setup -%% calls setsid, so plain inherit is not enough. For those we wrap with -%% util-linux `script` to allocate a PTY and relay keys via `io:get_chars` -%% (same path as the raw line editor — a competing file:read on /dev/pts -%% never sees keypresses while prim_tty owns the device). +%% Auth tools (`sudo`, `run0`, …) also need a controlling TTY; the PTY path +%% covers that. Host termios during children: cooked for OPOST/ONLCR (no +%% staircase) but ISIG off so Ctrl+C is readable as byte 3 instead of opening +%% the Erlang BREAK menu. %% --------------------------------------------------------------------------- --spec run_cmd(binary(), [binary()]) -> {ok, {integer(), binary()}} | {error, binary()}. -run_cmd(Command, Args) when is_binary(Command), is_list(Args) -> +-spec run_cmd(binary(), [binary()], binary()) -> + {ok, {integer(), binary()}} | {error, binary()}. +run_cmd(Command, Args, Stdin) when is_binary(Command), is_list(Args), is_binary(Stdin) -> case resolve_cmd(Command, Args) of {error, _} = E -> E; {ok, Path, PortArgs} -> try - run_cmd_capture(Path, PortArgs) + run_cmd_capture(Path, PortArgs, Stdin) catch _:Reason -> {error, reason_to_bin(Reason)} @@ -1134,8 +1318,16 @@ run_cmd(Command, Args) when is_binary(Command), is_list(Args) -> end. %% Foreground interactive: inherit TTY when possible. --spec run_cmd_tty(binary(), [binary()]) -> {ok, {integer(), binary()}} | {error, binary()}. -run_cmd_tty(Command, Args) when is_binary(Command), is_list(Args) -> +%% Non-empty Stdin is still fed (temp file + redirect) so `cat f | less` works. +%% +%% While the REPL uses OTP `{noshell, raw}`, prim_tty leaves termios with +%% OPOST/ONLCR off so a bare LF does not return the cursor to column 0. +%% Tools that write LF-only lines (fastfetch, many TUIs) look staircased if +%% they inherit that TTY. Wrap inherit/PTY runs in cooked mode and restore +%% raw afterwards (same idea as println/1 converting to CRLF for shell text). +-spec run_cmd_tty(binary(), [binary()], binary()) -> + {ok, {integer(), binary()}} | {error, binary()}. +run_cmd_tty(Command, Args, Stdin) when is_binary(Command), is_list(Args), is_binary(Stdin) -> case resolve_cmd(Command, Args) of {error, _} = E -> E; @@ -1143,14 +1335,18 @@ run_cmd_tty(Command, Args) when is_binary(Command), is_list(Args) -> try case stdout_isatty() of false -> - run_cmd_capture(Path, PortArgs); + run_cmd_capture(Path, PortArgs, Stdin); true -> - case {needs_controlling_tty(Path), os:find_executable("script"), find_tty_path()} of - {true, Script, {ok, Tty}} when is_list(Script) -> - run_cmd_pty(Script, Path, PortArgs, Tty); - _ -> - run_cmd_inherit(Path, PortArgs) - end + with_cooked_tty(fun() -> + %% PTY for all interactive when possible: key relay + %% sees Ctrl+C and can SIGINT the child process group. + case {os:find_executable("script"), find_tty_path()} of + {Script, {ok, Tty}} when is_list(Script) -> + run_cmd_pty(Script, Path, PortArgs, Tty, Stdin); + _ -> + run_cmd_inherit(Path, PortArgs, Stdin) + end + end) end catch _:Reason -> @@ -1158,6 +1354,134 @@ run_cmd_tty(Command, Args) when is_binary(Command), is_list(Args) -> end end. +%% Temporarily put the controlling TTY into cooked output + non-canonical +%% input for an external child, then restore previous termios (raw REPL). +%% +%% Applied whenever stdout is a TTY (not only raw REPL): -c under a terminal +%% still needs -isig/-icanon so Ctrl+C is a readable byte for the interrupt +%% path. No-op when stty/TTY is unavailable. +with_cooked_tty(Fun) when is_function(Fun, 0) -> + case stdout_isatty() of + false -> + Fun(); + true -> + case stty_save() of + undefined -> + %% Still try to apply host flags; restore is best-effort. + stty_sane(), + try + Fun() + after + ok + end; + Saved -> + stty_sane(), + try + Fun() + after + stty_restore(Saved) + end + end + end. + +stty_save() -> + case stty_run(["-g"]) of + {ok, Out} -> + case string:trim(Out, both, [$\s, $\t, $\n, $\r]) of + "" -> + undefined; + Settings -> + %% stty -g is a single token of colon-separated hex flags. + Settings + end; + _ -> + undefined + end. + +stty_sane() -> + %% Host TTY while an external runs under the raw REPL: + %% - sane / opost / onlcr: LF→CRLF so children don't staircase + %% - -isig: Ctrl+C is byte 3 (not kernel SIGINT → BEAM BREAK menu) + %% - -icanon min 1 time 0: deliver each byte immediately — with ICANON + %% left on, Ctrl+C sits in the line buffer until Enter and our key + %% relay never sees it (external freezes; second ^C looks wedged) + %% - -echo: host must not echo keys we relay into the child PTY + _ = stty_run([ + "sane", + "-isig", + "-icanon", + "min", + "1", + "time", + "0", + "-echo" + ]), + ok. + +stty_restore(Settings) when is_list(Settings) -> + _ = stty_run([Settings]), + ok; +stty_restore(_) -> + ok. + +%% Run stty against the real terminal device (not a pipe). Prefer the pts +%% path from /proc (same as sudo/PTY path), then `/dev/tty`. +%% +%% NOTE: do not use filelib:is_file/1 for `/dev/tty` — it is a device node, +%% so is_file returns false and would skip stty entirely (fastfetch staircase). +stty_run(Args) when is_list(Args) -> + case os:find_executable("stty") of + false -> + {error, no_stty}; + Stty when is_list(Stty) -> + stty_run_on(Stty, Args, stty_devices()) + end. + +stty_devices() -> + case find_tty_path() of + {ok, Path} -> + %% Prefer the concrete pts; /dev/tty is a fallback alias. + [Path, "/dev/tty"]; + _ -> + ["/dev/tty"] + end. + +stty_run_on(_Stty, _Args, []) -> + {error, no_tty}; +stty_run_on(Stty, Args, [Dev | Rest]) -> + case stty_on_device(Stty, Dev, Args) of + {ok, _} = Ok -> + Ok; + _ -> + stty_run_on(Stty, Args, Rest) + end. + +stty_on_device(Stty, Dev, Args) when is_list(Stty), is_list(Dev), is_list(Args) -> + try + Port = open_port( + {spawn_executable, Stty}, + [ + binary, + exit_status, + use_stdio, + stderr_to_stdout, + {args, ["-F", Dev | Args]} + ] + ), + %% No interrupt watch — internal helper, must not steal TTY input. + case collect_output_quiet(Port, <<>>, 5000) of + {ok, {0, Bin}} -> + {ok, unicode:characters_to_list(Bin)}; + {ok, {Status, Bin}} -> + {error, {Status, Bin}}; + {error, _} = E -> + E + end + catch + _:_ -> + {error, stty_failed} + end. + resolve_cmd(Command, Args) -> case os:find_executable(unicode:characters_to_list(Command)) of false -> @@ -1167,97 +1491,249 @@ resolve_cmd(Command, Args) -> {ok, Path, PortArgs} end. -%% Basename check for tools that need a controlling TTY (not just isatty). -needs_controlling_tty(Path) when is_list(Path) -> - Base = filename:basename(Path), - lists:member(Base, ["sudo", "run0", "pkexec", "doas", "su"]). - %% Capture mode: pipes, no TTY. `child_env` forces color when the shell wants %% it so tools like `jj` still embed ANSI we can pass through on display. %% -%% Stdin is redirected from /dev/null via `sh -c` so programs that read stdin -%% (bare `less`, `cat`, `wc`) get EOF immediately instead of hanging on an -%% open-but-never-written pipe. (Port option `out` alone breaks exit_status -%% delivery on current OTP.) -run_cmd_capture(Path, PortArgs) -> - Sh = - case os:find_executable("sh") of +%% Stdin is always redirected via `sh -c` + `$GLESHELL_STDIN` (either a temp +%% file with pipeline bytes, or `/dev/null`) so programs never hang on an +%% open-but-never-written Erlang port pipe. +run_cmd_capture(Path, PortArgs, Stdin) when is_binary(Stdin) -> + with_stdin_file(Stdin, fun(StdinPath) -> + sh_exec(Path, PortArgs, StdinPath, capture) + end). + +%% Inherit real stdio — pagers/editors talk to the terminal directly. +%% LESS=FRX (via child_env) lets less pass ANSI colors from jj/git. +%% +%% Empty stdin: pure inherit (bare `less` reads the TTY). +%% Non-empty stdin: still inherit stdout/stderr TTY, but redirect stdin from +%% a temp file so `cat file | less` pages the pipeline data. +%% +%% Ctrl+C: prefer the PTY path (key relay). Inherit is a fallback when +%% `script` is missing — host is -isig/-icanon so Ctrl+C is byte 3; a +%% watcher SIGINTs the child's process group (setsid means kernel SIGINT +%% never reaches the child even with ISIG on). +run_cmd_inherit(Path, PortArgs, Stdin) when is_binary(Stdin) -> + case Stdin of + <<>> -> + Port = open_port( + {spawn_executable, Path}, + [ + exit_status, + nouse_stdio, + {env, child_env()}, + {args, PortArgs} + ] + ), + put(gleshell_output_shown, true), + await_port_exit_interruptible(Port); + _ -> + with_stdin_file(Stdin, fun(StdinPath) -> + sh_exec(Path, PortArgs, StdinPath, inherit) + end) + end. + +%% PTY + key relay (interactive TTY, sudo/run0, …). +%% +%% util-linux `script` does NOT exec the argv after `--` directly. It runs +%% `$SHELL -c ""` (see script(1)). Nested +%% `sh -c 'exec "$0" …' path` therefore becomes one mangled shell string and +%% the real binary never runs (fastfetch → empty output, exit 0). +%% +%% Empty stdin: pass Path/args through as simple tokens (`script -- cmd args`). +%% Non-empty stdin (pipeline → less): write a one-shot runner script that +%% redirects and execs, then `script -- /tmp/runner` (single path token). +run_cmd_pty(Script, Path, PortArgs, TtyPath, <<>>) -> + run_cmd_pty_argv(Script, [Path | PortArgs], TtyPath, Path, PortArgs, <<>>); +run_cmd_pty(Script, Path, PortArgs, TtyPath, Stdin) when is_binary(Stdin) -> + with_stdin_file(Stdin, fun(StdinPath) -> + with_exec_runner(Path, PortArgs, StdinPath, fun(Runner) -> + run_cmd_pty_argv(Script, [Runner], TtyPath, Path, PortArgs, Stdin) + end) + end). + +run_cmd_pty_argv(Script, Argv, TtyPath, Path, PortArgs, Stdin) when is_list(Argv) -> + with_trapped_exits(fun() -> + Port = open_port( + {spawn_executable, Script}, + [ + binary, + exit_status, + stderr_to_stdout, + use_stdio, + stream, + {env, child_env()}, + {args, ["-q", "-e", "/dev/null", "--" | Argv]} + ] + ), + case file:open(TtyPath, [write, raw, binary]) of + {ok, TtyOut} -> + GL = group_leader(), + Reader = spawn(fun() -> + group_leader(GL, self()), + io_to_port(Port) + end), + put(gleshell_output_shown, true), + try + collect_output_relay(Port, TtyOut, <<>>) + after + exit(Reader, kill), + catch file:close(TtyOut), + catch port_close(Port) + end; + {error, _} -> + catch port_close(Port), + put(gleshell_output_shown, false), + run_cmd_inherit(Path, PortArgs, Stdin) + end + end). + +%% One-shot `#!/bin/sh` runner: exec Path with PortArgs, stdin from StdinPath. +%% Needed because `script` flattens argv into `$SHELL -c` (no real multi-arg exec). +with_exec_runner(Path, PortArgs, StdinPath, Fun) when is_function(Fun, 1) -> + case write_runner_script(Path, PortArgs, StdinPath) of + {ok, Runner} -> + try + Fun(Runner) + after + _ = file:delete(Runner) + end; + {error, Reason} -> + {error, reason_to_bin({runner_script, Reason})} + end. + +write_runner_script(Path, PortArgs, StdinPath) -> + Dir = + case os:getenv("TMPDIR") of false -> - "/bin/sh"; - S -> - S + "/tmp"; + "" -> + "/tmp"; + D -> + D end, - %% sh -c 'exec "$0" "$@" < /dev/null' path arg1 arg2 ... - %% $0 = Path; "$@" = remaining args — no shell-quoting of user args. + Name = + filename:join( + Dir, + "gleshell-run-" ++ integer_to_list(erlang:unique_integer([positive])) + ), + Body = runner_script_body(Path, PortArgs, StdinPath), + case file:write_file(Name, Body) of + ok -> + case file:change_mode(Name, 8#755) of + ok -> + {ok, Name}; + {error, _} = E -> + _ = file:delete(Name), + E + end; + {error, _} = E -> + E + end. + +runner_script_body(Path, PortArgs, StdinPath) -> + ArgsQ = [[$\s, shell_single_quote(A)] || A <- PortArgs], + [ + "#!/bin/sh\n", + "exec ", + shell_single_quote(Path), + ArgsQ, + " < ", + shell_single_quote(StdinPath), + "\n" + ]. + +%% Safe single-quoted shell token (`foo'bar` → `'foo'\''bar'`). +shell_single_quote(S) when is_list(S) -> + [$' | shell_single_quote_chars(S) ++ "'"]; +shell_single_quote(B) when is_binary(B) -> + shell_single_quote(unicode:characters_to_list(B)). + +shell_single_quote_chars([]) -> + []; +shell_single_quote_chars([$' | Rest]) -> + "'\\''" ++ shell_single_quote_chars(Rest); +shell_single_quote_chars([C | Rest]) -> + [C | shell_single_quote_chars(Rest)]. + +find_sh() -> + case os:find_executable("sh") of + false -> + "/bin/sh"; + S -> + S + end. + +%% Run Path with stdin redirected from StdinPath. +%% Mode `capture` uses pipes; `inherit` uses nouse_stdio (real TTY for out/err). +sh_exec(Path, PortArgs, StdinPath, capture) -> Port = open_port( - {spawn_executable, Sh}, + {spawn_executable, find_sh()}, [ binary, exit_status, stderr_to_stdout, use_stdio, stream, - {env, child_env()}, - {args, ["-c", "exec \"$0\" \"$@\" < /dev/null", Path | PortArgs]} + {env, [{"GLESHELL_STDIN", StdinPath} | child_env()]}, + {args, ["-c", "exec \"$0\" \"$@\" < \"$GLESHELL_STDIN\"", Path | PortArgs]} ] ), put(gleshell_output_shown, false), - collect_output(Port, <<>>). - -%% Inherit real stdio — pagers/editors talk to the terminal directly. -%% LESS=FRX (via child_env) lets less pass ANSI colors from jj/git. -run_cmd_inherit(Path, PortArgs) -> + collect_output(Port, <<>>); +sh_exec(Path, PortArgs, StdinPath, inherit) -> Port = open_port( - {spawn_executable, Path}, + {spawn_executable, find_sh()}, [ exit_status, nouse_stdio, - {env, child_env()}, - {args, PortArgs} + {env, [{"GLESHELL_STDIN", StdinPath} | child_env()]}, + {args, ["-c", "exec \"$0\" \"$@\" < \"$GLESHELL_STDIN\"", Path | PortArgs]} ] ), put(gleshell_output_shown, true), - %% No timeout: less/vim/top may run for a long time. - receive - {Port, {exit_status, Status}} -> - {ok, {Status, <<>>}} - end. - -%% Controlling-TTY + key relay for sudo/run0/etc. -run_cmd_pty(Script, Path, PortArgs, TtyPath) -> - Port = open_port( - {spawn_executable, Script}, - [ - binary, - exit_status, - stderr_to_stdout, - use_stdio, - stream, - {env, child_env()}, - {args, ["-q", "-e", "/dev/null", "--", Path | PortArgs]} - ] - ), - case file:open(TtyPath, [write, raw, binary]) of - {ok, TtyOut} -> - GL = group_leader(), - Reader = spawn(fun() -> - group_leader(GL, self()), - io_to_port(Port) - end), - put(gleshell_output_shown, true), + await_port_exit_interruptible(Port). + +%% Provide a filesystem path for stdin bytes; clean up temp files afterwards. +with_stdin_file(<<>>, Fun) when is_function(Fun, 1) -> + Fun("/dev/null"); +with_stdin_file(Data, Fun) when is_binary(Data), is_function(Fun, 1) -> + case write_stdin_tmp(Data) of + {ok, Path} -> try - collect_output_relay(Port, TtyOut, <<>>) + Fun(Path) after - exit(Reader, kill), - catch file:close(TtyOut) + _ = file:delete(Path) end; - {error, _} -> - put(gleshell_output_shown, false), - %% Fall back to inherit rather than a silent capture hang. - run_cmd_inherit(Path, PortArgs) + {error, Reason} -> + {error, reason_to_bin({stdin_tmp, Reason})} + end. + +write_stdin_tmp(Data) when is_binary(Data) -> + Dir = + case os:getenv("TMPDIR") of + false -> + "/tmp"; + "" -> + "/tmp"; + D -> + D + end, + Name = + filename:join( + Dir, + "gleshell-stdin-" ++ integer_to_list(erlang:unique_integer([positive])) + ), + case file:write_file(Name, Data) of + ok -> + {ok, Name}; + {error, _} = E -> + E end. %% Relay keypresses from the group leader to the child's PTY (script stdin). +%% Ctrl+C (ETX / byte 3): SIGINT the child process group, and still write the +%% byte so the PTY line discipline can deliver SIGINT on the slave side too. io_to_port(Port) -> case io:get_chars("", 1) of eof -> @@ -1268,6 +1744,10 @@ io_to_port(Port) -> case io_data_to_bin(Data) of <<>> -> io_to_port(Port); + <<3>> = Bin -> + signal_port_group(Port, int), + catch port_command(Port, Bin), + io_to_port(Port); Bin -> catch port_command(Port, Bin), io_to_port(Port) @@ -1288,21 +1768,137 @@ io_data_to_bin(List) when is_list(List) -> io_data_to_bin(_) -> <<>>. +%% When a port's OS process is killed (Ctrl+C → SIGINT), the linked port may +%% exit with `epipe` / signal reasons. Without trap_exit the shell process +%% dies with "Erlang exit: Epipe" instead of returning to the prompt. +with_trapped_exits(Fun) when is_function(Fun, 0) -> + Old = process_flag(trap_exit, true), + try + Fun() + after + process_flag(trap_exit, Old), + drain_exit_msgs() + end. + +drain_exit_msgs() -> + receive + {'EXIT', _, _} -> + drain_exit_msgs() + after 0 -> + ok + end. + +%% Inherit path: watch for Ctrl+C (byte 3) and SIGINT the child group. +%% Used when `script`/PTY is unavailable; same kill path as the PTY relay. +await_port_exit_interruptible(Port) -> + with_trapped_exits(fun() -> + with_interrupt_watch(Port, fun() -> + await_port_exit_interruptible_loop(Port) + end) + end). + +await_port_exit_interruptible_loop(Port) -> + receive + {Port, {exit_status, Status}} -> + {ok, {Status, <<>>}}; + {'EXIT', Port, _Reason} -> + {ok, {130, <<>>}}; + {gleshell_interrupt, _} -> + signal_port_group(Port, int), + await_port_exit_after_interrupt(Port, 2000) + end. + +await_port_exit_after_interrupt(Port, GraceMs) -> + receive + {Port, {exit_status, Status}} -> + {ok, {Status, <<>>}}; + {'EXIT', Port, _Reason} -> + {ok, {130, <<>>}}; + {gleshell_interrupt, _} -> + signal_port_group(Port, kill), + await_port_exit_after_interrupt(Port, 1000) + after GraceMs -> + signal_port_group(Port, kill), + catch port_close(Port), + receive + {Port, {exit_status, Status}} -> + {ok, {Status, <<>>}}; + {'EXIT', Port, _} -> + {ok, {130, <<>>}} + after 1000 -> + {ok, {130, <<>>}} + end + end. + collect_output(Port, Acc) -> + with_trapped_exits(fun() -> + with_interrupt_watch(Port, fun() -> + collect_output_loop(Port, Acc, 120_000) + end) + end). + +collect_output_loop(Port, Acc, Timeout) -> receive {Port, {data, Data}} when is_binary(Data) -> - collect_output(Port, <>); + collect_output_loop(Port, <>, Timeout); {Port, {data, Data}} when is_list(Data) -> Bin = unicode:characters_to_binary(Data), - collect_output(Port, <>); + collect_output_loop(Port, <>, Timeout); {Port, {exit_status, Status}} -> - {ok, {Status, Acc}} - after 120_000 -> + {ok, {Status, Acc}}; + {'EXIT', Port, _Reason} -> + {ok, {130, Acc}}; + {gleshell_interrupt, _} -> + signal_port_group(Port, int), + collect_output_after_interrupt(Port, Acc, 2000) + after Timeout -> + signal_port_group(Port, term), catch port_close(Port), {error, <<"command timed out after 120s">>} end. -%% PTY auth session: no timeout (password prompts, etc.). +collect_output_after_interrupt(Port, Acc, GraceMs) -> + receive + {Port, {data, Data}} when is_binary(Data) -> + collect_output_after_interrupt( + Port, <>, GraceMs + ); + {Port, {data, Data}} when is_list(Data) -> + Bin = unicode:characters_to_binary(Data), + collect_output_after_interrupt( + Port, <>, GraceMs + ); + {Port, {exit_status, Status}} -> + {ok, {Status, Acc}}; + {'EXIT', Port, _Reason} -> + {ok, {130, Acc}}; + {gleshell_interrupt, _} -> + signal_port_group(Port, kill), + collect_output_after_interrupt(Port, Acc, 1000) + after GraceMs -> + signal_port_group(Port, kill), + catch port_close(Port), + receive + {Port, {data, Data}} when is_binary(Data) -> + collect_output_after_interrupt( + Port, <>, 500 + ); + {Port, {data, Data}} when is_list(Data) -> + Bin = unicode:characters_to_binary(Data), + collect_output_after_interrupt( + Port, <>, 500 + ); + {Port, {exit_status, Status}} -> + {ok, {Status, Acc}}; + {'EXIT', Port, _} -> + {ok, {130, Acc}} + after 1000 -> + {ok, {130, Acc}} + end + end. + +%% PTY session: no timeout (password prompts, long pagers, etc.). +%% Ctrl+C is handled in io_to_port/1 (SIGINT); also accept interrupt msgs. collect_output_relay(Port, Tty, Acc) -> receive {Port, {data, Data}} when is_binary(Data) -> @@ -1313,7 +1909,175 @@ collect_output_relay(Port, Tty, Acc) -> _ = file:write(Tty, Bin), collect_output_relay(Port, Tty, <>); {Port, {exit_status, Status}} -> - {ok, {Status, normalize_pty_output(Acc)}} + {ok, {Status, normalize_pty_output(Acc)}}; + {gleshell_interrupt, _} -> + signal_port_group(Port, int), + collect_output_relay_after_interrupt(Port, Tty, Acc, 2000) + end. + +collect_output_relay_after_interrupt(Port, Tty, Acc, GraceMs) -> + receive + {Port, {data, Data}} when is_binary(Data) -> + _ = file:write(Tty, Data), + collect_output_relay_after_interrupt( + Port, Tty, <>, GraceMs + ); + {Port, {data, Data}} when is_list(Data) -> + Bin = unicode:characters_to_binary(Data), + _ = file:write(Tty, Bin), + collect_output_relay_after_interrupt( + Port, Tty, <>, GraceMs + ); + {Port, {exit_status, Status}} -> + {ok, {Status, normalize_pty_output(Acc)}}; + {gleshell_interrupt, _} -> + signal_port_group(Port, kill), + collect_output_relay_after_interrupt(Port, Tty, Acc, 1000) + after GraceMs -> + signal_port_group(Port, kill), + catch port_close(Port), + receive + {Port, {exit_status, Status}} -> + {ok, {Status, normalize_pty_output(Acc)}} + after 1000 -> + {ok, {130, normalize_pty_output(Acc)}} + end + end. + +%% --------------------------------------------------------------------------- +%% Ctrl+C / SIGINT forwarding +%% +%% BEAM's open_port → erl_child_setup → setsid, so the child is not in the +%% terminal foreground group. Host ISIG is left off while a child runs; we +%% watch for byte 3 (ETX) and kill(-pid, SIGINT) on the child's process group. +%% --------------------------------------------------------------------------- + +with_interrupt_watch(_Port, Fun) when is_function(Fun, 0) -> + Parent = self(), + GL = group_leader(), + Watcher = + case can_watch_interrupt() of + true -> + spawn(fun() -> + group_leader(GL, self()), + interrupt_watch_loop(Parent) + end); + false -> + undefined + end, + try + Fun() + after + case Watcher of + undefined -> + ok; + W -> + exit(W, kill), + drain_interrupt_msgs() + end + end. + +%% Collect port output without Ctrl+C watching (stty and other helpers). +collect_output_quiet(Port, Acc, Timeout) -> + receive + {Port, {data, Data}} when is_binary(Data) -> + collect_output_quiet(Port, <>, Timeout); + {Port, {data, Data}} when is_list(Data) -> + Bin = unicode:characters_to_binary(Data), + collect_output_quiet(Port, <>, Timeout); + {Port, {exit_status, Status}} -> + {ok, {Status, Acc}} + after Timeout -> + catch port_close(Port), + {error, <<"command timed out">>} + end. + +%% Watch when the REPL owns the TTY (raw mode) or stdin is a terminal. +can_watch_interrupt() -> + case get(gleshell_raw) of + true -> + true; + _ -> + stdout_isatty() + end. + +interrupt_watch_loop(Parent) when is_pid(Parent) -> + case catch io:get_chars("", 1) of + eof -> + ok; + {error, _} -> + ok; + {'EXIT', _} -> + ok; + Data -> + case io_data_to_bin(Data) of + <<3>> -> + Parent ! {gleshell_interrupt, self()}, + interrupt_watch_loop(Parent); + _ -> + %% Non-Ctrl+C: discard here (capture mode). Interactive + %% PTY uses io_to_port instead; inherit prefers PTY. + interrupt_watch_loop(Parent) + end + end. + +drain_interrupt_msgs() -> + receive + {gleshell_interrupt, _} -> + drain_interrupt_msgs() + after 0 -> + ok + end. + +%% SIGINT/SIGTERM/SIGKILL the port's OS process group (setsid → pgid = pid). +signal_port_group(Port, Sig) when is_port(Port) -> + case erlang:port_info(Port, os_pid) of + {os_pid, Pid} when is_integer(Pid), Pid > 0 -> + kill_os_group(Pid, Sig); + _ -> + ok + end. + +kill_os_group(Pid, Sig) when is_integer(Pid) -> + Kill = + case os:find_executable("kill") of + false -> + "kill"; + K -> + K + end, + SigArg = + case Sig of + int -> + "-INT"; + term -> + "-TERM"; + kill -> + "-KILL" + end, + %% Negative pid → process group (child is session/group leader after setsid). + Pg = "-" ++ integer_to_list(Pid), + Single = integer_to_list(Pid), + _ = kill_once(Kill, [SigArg, Pg]), + _ = kill_once(Kill, [SigArg, Single]), + ok. + +kill_once(Kill, Args) -> + try + Port = open_port( + {spawn_executable, Kill}, + [exit_status, nouse_stdio, {args, Args}] + ), + receive + {Port, {exit_status, _}} -> + ok + after 1000 -> + catch port_close(Port), + ok + end + catch + _:_ -> + ok end. %% PTY line discipline often emits CR-LF; normalize to LF for structured use. diff --git a/test/gleshell_test.gleam b/test/gleshell_test.gleam index 8b73c84..0ba6fc2 100644 --- a/test/gleshell_test.gleam +++ b/test/gleshell_test.gleam @@ -1,5 +1,7 @@ +import gleam/list import gleam/string import gleeunit +import gleshell/builtins import gleshell/color import gleshell/display import gleshell/env @@ -136,6 +138,22 @@ pub fn eval_pipeline_reverse_first_test() { Nil } +/// Pipeline input must become the external's stdin (`cat f | less`, `echo hi | wc`). +/// Use `let` so the last stage is capture mode even on a TTY (bare expressions +/// may inherit the terminal and return an empty string value). +pub fn eval_pipeline_stdin_to_external_test() { + let env = env.new() + // `echo` is a builtin; `wc` is external. Count bytes of "hello" (no trailing NL). + let assert eval.Continue(_, String(out)) = + eval.eval_source(env, "let n = echo hello | ^wc -c") + let assert True = string.contains(out, "5") + // Tight `cmd|cmd` form (no spaces around pipe) + let assert eval.Continue(_, String(out2)) = + eval.eval_source(env, "let m = echo ab|^wc -c") + let assert True = string.contains(out2, "2") + Nil +} + pub fn eval_let_and_var_test() { let env = env.new() let assert eval.Continue(env2, Int(7)) = @@ -213,6 +231,41 @@ pub fn eval_where_select_test() { Nil } +pub fn help_covers_all_builtins_test() { + // Every registered builtin must have a dedicated help_text entry. + let assert [] = builtins.missing_help() + + let env = env.new() + // which finds builtins; help must too (not "unknown command") + let assert eval.Continue(_, String(which_out)) = + eval.eval_source(env, "which table") + let assert "builtin: table" = which_out + let assert eval.Continue(_, String(help_out)) = + eval.eval_source(env, "help table") + let assert True = string.contains(help_out, "table") + let assert True = string.contains(help_out, "coerce") + + // Parent commands with subcommands: `help to` / `help from` must resolve. + let assert eval.Continue(_, String(to_help)) = + eval.eval_source(env, "help to") + let assert True = string.contains(to_help, "json") + let assert eval.Continue(_, String(from_help)) = + eval.eval_source(env, "help from") + let assert True = string.contains(from_help, "json") + + // Bare help lists every command with its one-line description. + let assert eval.Continue(_, String(all)) = eval.eval_source(env, "help") + list.each(builtins.names(), fn(name) { + let assert True = string.contains(all, name) + }) + + let assert eval.Continue(env2, value.Fail(msg)) = + eval.eval_source(env, "help not-a-real-cmd") + let assert True = string.contains(msg, "unknown command") + let assert 1 = env2.last_exit + Nil +} + pub fn eval_from_json_test() { let env = env.new() let assert eval.Continue(_, Record(fields)) = @@ -223,7 +276,7 @@ pub fn eval_from_json_test() { pub fn eval_to_json_pretty_test() { let env = env.new() - // Nushell-style multi-word `to json` — pretty by default + // `to` command + `json` subcommand — pretty by default let assert eval.Continue(_, String(pretty)) = eval.eval_source(env, "echo [1 2 3] | to json") let assert True = string.contains(pretty, "\n") @@ -232,6 +285,14 @@ pub fn eval_to_json_pretty_test() { let assert eval.Continue(_, String(raw)) = eval.eval_source(env, "echo [1 2 3] | to json --raw") let assert "[1,2,3]" = raw + // Missing / unknown subcommand + let assert eval.Continue(env2, value.Fail(msg)) = + eval.eval_source(env, "echo 1 | to") + let assert True = string.contains(msg, "subcommand") + let assert 1 = env2.last_exit + let assert eval.Continue(_, value.Fail(msg2)) = + eval.eval_source(env, "echo 1 | to yaml") + let assert True = string.contains(msg2, "unknown subcommand") Nil } @@ -273,6 +334,69 @@ pub fn eval_which_builtin_test() { Nil } +pub fn eval_find_list_test() { + let env = env.new() + // Substring match (OR across terms) + let assert eval.Continue(_, List(items)) = + eval.eval_source(env, "echo [moe larry curly] | find l") + let assert [String("larry"), String("curly")] = items + // Multiple terms + let assert eval.Continue(_, List(items2)) = + eval.eval_source(env, "echo [a.toml b.md c.rs] | find toml md") + let assert [String("a.toml"), String("b.md")] = items2 + // Exact number match + let assert eval.Continue(_, List([Int(5)])) = + eval.eval_source(env, "echo [1 5 3 4 35] | find 5") + Nil +} + +pub fn eval_find_ignore_case_invert_test() { + let env = env.new() + let assert eval.Continue(_, List(items)) = + eval.eval_source(env, "echo [Hello world HELLO] | find hello -i") + let assert [String("Hello"), String("HELLO")] = items + // `-i term` may attach term to the flag; still works + let assert eval.Continue(_, List(items2)) = + eval.eval_source(env, "echo [Hello world HELLO] | find -i hello") + let assert [String("Hello"), String("HELLO")] = items2 + let assert eval.Continue(_, List([String("cd")])) = + eval.eval_source(env, "echo [ab cd] | find --invert a") + Nil +} + +pub fn eval_find_table_and_string_test() { + let env = env.new() + let assert eval.Continue(_, Table(["name", "type"], rows)) = + eval.eval_source( + env, + "echo [{name: a, type: file} {name: b, type: dir}] | table | find file", + ) + let assert [[String("a"), String("file")]] = rows + // Single-line string: return the string if match, else nothing + let assert eval.Continue(_, String("Cargo.toml")) = + eval.eval_source(env, "echo Cargo.toml | find Cargo") + let assert eval.Continue(_, Nothing) = + eval.eval_source(env, "echo Cargo.toml | find zz") + // Multi-line string → list of matching lines + let assert eval.Continue(_, List(lines)) = + eval.eval_source(env, "echo \"hi\nbye\nhi there\" | find hi") + let assert [String("hi"), String("hi there")] = lines + Nil +} + +pub fn eval_find_regex_test() { + let env = env.new() + let assert eval.Continue(_, List(items)) = + eval.eval_source(env, "echo [abc odb arc abf] | find --regex \"b.\"") + let assert [String("abc"), String("abf")] = items + // Regex + terms is rejected (Nu-compatible) + let assert eval.Continue(env2, value.Fail(msg)) = + eval.eval_source(env, "echo [abc] | find --regex \"b.\" x") + let assert True = string.contains(msg, "regex") + let assert 1 = env2.last_exit + Nil +} + pub fn eval_which_all_test() { let env = env.new() // `-a` includes the builtin first, then any PATH copies of `ls`. @@ -345,6 +469,30 @@ pub fn display_table_headers_colored_test() { Nil } +pub fn format_filesize_units_test() { + let assert "0 B" = display.format_filesize(0) + let assert "512 B" = display.format_filesize(512) + let assert "1023 B" = display.format_filesize(1023) + let assert "1 KB" = display.format_filesize(1024) + let assert "1.5 KB" = display.format_filesize(1536) + let assert "1 MB" = display.format_filesize(1_048_576) + let assert "1.5 MB" = display.format_filesize(1_048_576 + 524_288) + let assert "1 GB" = display.format_filesize(1_073_741_824) + Nil +} + +pub fn display_size_column_humanized_test() { + // Data stays as raw bytes (Int); only display shows KB/MB. + let text = + display.render_with( + False, + Table(["name", "size"], [[String("a"), Int(2048)]]), + ) + let assert True = string_contains(text, "2 KB") + let assert False = string_contains(text, "2048") + Nil +} + pub fn color_visible_length_strips_ansi_test() { let painted = color.paint(True, "\u{001b}[32m", "hi") let assert 2 = color.visible_length(painted) @@ -399,9 +547,11 @@ pub fn highlight_incomplete_string_test() { Nil } -pub fn highlight_to_json_multiword_test() { +pub fn highlight_to_command_test() { + // `to` is a builtin; `json` is a subcommand arg let text = highlight.highlight(True, "range 3 | to json") - let assert True = string_contains(text, "to json") + let assert True = string_contains(text, "to") + let assert True = string_contains(text, "json") let assert True = string_contains(text, "\u{001b}[1;36m") Nil } @@ -422,3 +572,68 @@ fn string_contains(haystack: String, needle: String) -> Bool { _ -> True } } + +// --- tab completion --- + +pub fn complete_command_builtin_test() { + // Start of line: complete builtins + let #(matches, kind) = sys.complete_word("", "ech") + let assert True = kind == "command" + let assert True = list_contains(matches, "echo") + // After pipeline separator + let #(matches2, kind2) = sys.complete_word("ls | ", "wher") + let assert True = kind2 == "command" + let assert True = list_contains(matches2, "where") + // `to` / `from` are single-word commands (subcommand is a normal arg) + let #(matches3, kind3) = sys.complete_word("", "to") + let assert True = kind3 == "command" + let assert True = list_contains(matches3, "to") + let #(matches_from, kind_from) = sys.complete_word("", "fro") + let assert True = kind_from == "command" + let assert True = list_contains(matches_from, "from") + // Keyword + let #(matches4, kind4) = sys.complete_word("", "le") + let assert True = kind4 == "command" + let assert True = list_contains(matches4, "let") + Nil +} + +pub fn complete_command_after_assign_test() { + let #(matches, kind) = sys.complete_word("let x = ", "ran") + let assert True = kind == "command" + let assert True = list_contains(matches, "range") + Nil +} + +pub fn complete_path_for_args_test() { + // After a command name, Tab completes files not commands + let #(_matches, kind) = sys.complete_word("echo ", "ech") + let assert True = kind == "path" + Nil +} + +pub fn complete_path_like_command_test() { + // Path-shaped command words stay on filename completion + let #(_matches, kind) = sys.complete_word("", "./ec") + let assert True = kind == "path" + let #(_matches2, kind2) = sys.complete_word("", "/us") + let assert True = kind2 == "path" + Nil +} + +pub fn complete_path_executable_test() { + // PATH + builtins for a non-empty prefix + let #(matches, kind) = sys.complete_word("", "s") + let assert True = kind == "command" + // At least builtins starting with s (select, skip, sort-by, …) + let assert True = list_contains(matches, "select") + Nil +} + +fn list_contains(items: List(String), needle: String) -> Bool { + case items { + [] -> False + [x, ..] if x == needle -> True + [_, ..rest] -> list_contains(rest, needle) + } +}