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 raw-mode REPL with Nu-style highlighting and $env.

Live syntax highlighting, Tab path completion, and Ctrl+C line cancel
via a raw TTY editor (+Bc). Process environment uses Nushell-style
$env / $env.VAR / $env.FOO = value; non-TTY falls back to edlin.

+2072 -94
+12
.envrc
··· 1 + #!/usr/bin/env bash 2 + 3 + if ! has nix_direnv_version || ! nix_direnv_version 3.1.0; then 4 + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.1.0/direnvrc" "sha256-yMJ2OVMzrFaDPn7q8nCBZFRYpL/f0RcHzhmw/i6btJM=" 5 + fi 6 + 7 + watch_file flake.nix 8 + watch_file flake.lock 9 + watch_file devenv.nix 10 + if ! use flake . --no-pure-eval; then 11 + echo "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2 12 + fi
+24 -7
README.md
··· 19 19 20 20 ```bash 21 21 # with Nix flake (dev shell: gleam, erlang, rebar3) 22 - nix develop 22 + # --no-pure-eval lets devenv use the real project dir (not /nix/store) 23 + nix develop --no-pure-eval 23 24 gleam run # interactive REPL 24 25 gleam run -- -c 'ls | first 3' 25 26 gleam test ··· 33 34 34 35 ### REPL editing 35 36 36 - The interactive REPL uses Erlang’s line editor (`edlin`): 37 + On a TTY the interactive REPL uses a **raw-mode line editor** with 38 + **Nushell-style syntax highlighting** as you type (commands cyan, strings 39 + green, numbers purple, pipes purple, flags blue, variables purple, …). 37 40 38 41 | Key | Action | 39 42 |-----|--------| 40 - | ↑ / ↓ or Ctrl+P / Ctrl+N | History | 43 + | ↑ / ↓ | History | 44 + | **Tab** | Filename completion (common prefix; list matches if ambiguous) | 41 45 | **Ctrl+R** | Reverse-i-search through history | 42 46 | Ctrl+A / Ctrl+E | Beginning / end of line | 43 47 | Ctrl+W | Delete previous word | 44 - | Ctrl+C / Ctrl+G | Cancel search / interrupt | 48 + | Ctrl+U / Ctrl+K | Kill to start / end of line | 49 + | Ctrl+L | Clear screen | 50 + | **Ctrl+C** | Cancel current line (does not exit the shell) | 51 + | Ctrl+D | EOF (empty line) or delete under cursor | 45 52 46 - History is persisted under the user cache as `gleshell-history` (OTP `shell_history`). 53 + History is persisted under the user cache as `gleshell-history/lines`. 54 + Non-TTY input falls back to Erlang’s `edlin`/`get_until` path. 47 55 48 56 ## Examples 49 57 ··· 64 72 # variables 65 73 let n = range 3 | length 66 74 echo $n 75 + 76 + # process environment (Nushell-style) 77 + $env.HOME 78 + $env | get PATH 79 + $env.MY_VAR = hello 80 + echo $env.MY_VAR 67 81 68 82 # external programs (stdout captured as a string) 69 83 ^uname -a ··· 82 96 | Lists | `[1 2 3]` | 83 97 | Records | `{name: alice, age: 30}` | 84 98 | Variables | `let x = …` then `$x` (pipeline input is `$in`) | 99 + | Env | `$env`, `$env.HOME`, `$env.FOO = value` | 85 100 | Flags | `--flag` / `--flag value` | 86 101 | Force external | `^command args…` | 87 102 | Comments | `# …` | ··· 109 124 builtins.gleam # Nu-inspired commands 110 125 display.gleam # table pretty-printer 111 126 color.gleam # Nushell-style ANSI colors 127 + highlight.gleam # live input syntax highlighting 112 128 env.gleam / sys.gleam 113 129 ``` 114 130 115 - Output is colorized on a TTY (headers bold green, numbers purple, bools cyan, 116 - dirs blue, errors red, …). Disable with `NO_COLOR=1`; force with `FORCE_COLOR=1`. 131 + Input and output are colorized on a TTY (Nu-like shapes on the command line; 132 + headers bold green, numbers purple, bools cyan, dirs blue, errors red, …). 133 + Disable with `NO_COLOR=1`; force with `FORCE_COLOR=1`. 117 134 118 135 ## Status 119 136
+3
devenv.nix
··· 8 8 ]; 9 9 10 10 enterShell = '' 11 + # +Bc: Ctrl+C cancels the line instead of the Erlang BREAK menu (abort). 12 + # gleshell also re-execs with +Bc if this is unset; exporting avoids the hop. 13 + export ERL_AFLAGS="+Bc ''${ERL_AFLAGS:-}" 11 14 echo "gleshell devenv — gleam run / gleam test / gleam run -- -c 'ls | first 3'" 12 15 ''; 13 16 }
+5 -6
flake.nix
··· 51 51 fi 52 52 fi 53 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:-}" 54 56 exec gleam run -- "$@" 55 57 ''; 56 58 }; ··· 74 76 pkgs = nixpkgs.legacyPackages.${system}; 75 77 in 76 78 { 79 + # --no-pure-eval (or direnv) is required so devenv can resolve 80 + # devenv.root from $PWD; pure eval would point ./. at the store. 77 81 default = devenv.lib.mkShell { 78 82 inherit inputs pkgs; 79 - modules = [ 80 - { 81 - devenv.root = builtins.toString ./.; 82 - } 83 - ./devenv.nix 84 - ]; 83 + modules = [ ./devenv.nix ]; 85 84 }; 86 85 } 87 86 );
+22 -10
src/gleshell.gleam
··· 27 27 } 28 28 29 29 fn print_usage() -> Nil { 30 - io.println(string.join( 30 + sys.println(string.join( 31 31 [ 32 32 "gleshell — Gleam shell inspired by Nushell", 33 33 "", ··· 62 62 } 63 63 64 64 fn repl(env: env.Env) -> Nil { 65 - io.println( 66 - "gleshell 0.1 — structured data shell (type `help`, `exit` to quit; Ctrl+R reverse search)", 65 + sys.println( 66 + "gleshell 0.1 — structured data shell (type `help`, `exit` to quit; Tab completes paths, Ctrl+R search)", 67 67 ) 68 68 repl_loop(env) 69 69 } ··· 72 72 let prompt = prompt_for(env) 73 73 case sys.get_line(prompt) { 74 74 Error("eof") -> { 75 - io.println("") 75 + sys.println("") 76 76 Nil 77 + } 78 + // Ctrl+C cancelled the line (edlin path); stay in the REPL. 79 + Error("interrupted") -> { 80 + sys.println("") 81 + repl_loop(env) 77 82 } 78 83 Error(e) -> { 79 84 io.println_error("read error: " <> e) ··· 140 145 fn print_value(value: value.Value) -> Nil { 141 146 case value { 142 147 Nothing -> Nil 143 - _ -> { 144 - let text = display.render(value) 145 - case text { 146 - "" -> Nil 147 - t -> io.println(t) 148 + _ -> 149 + // External commands that used PTY relay already streamed output to the 150 + // terminal (needed for run0/sudo password prompts). Skip re-printing 151 + // when that flag is still set (final pipeline stage was that external). 152 + case sys.take_output_shown() { 153 + True -> Nil 154 + False -> { 155 + let text = display.render(value) 156 + case text { 157 + "" -> Nil 158 + t -> sys.println(t) 159 + } 160 + } 148 161 } 149 - } 150 162 } 151 163 } 152 164
+22 -13
src/gleshell/builtins.gleam
··· 124 124 "", 125 125 "Use `help <command>` for details. `^cmd` forces an external binary.", 126 126 "Variables: `let x = ...` then `$x`. Pipeline input is `$in`.", 127 + "Env: `$env`, `$env.HOME`, `$env.FOO = bar` (process environment).", 127 128 ]), 128 129 ) 129 130 ok(env, String(string.join(lines, "\n"))) ··· 159 160 "from json — parse JSON string input into structured data", 160 161 ), 161 162 #("range", "range <end> | range <start> <end> — integer range list"), 163 + #( 164 + "env", 165 + "env [NAME] — process environment table, or one var (same as `$env` / `$env.NAME`)", 166 + ), 162 167 #("sys", "sys — host info record"), 163 168 ]) 164 169 } ··· 971 976 _flags: dict.Dict(String, Value), 972 977 ) -> BuiltinResult { 973 978 case args { 974 - [String(name)] -> ok(env, env.get_var(env, name)) 979 + [String(name)] -> ok(env, env.get_var(env, "env." <> name)) 975 980 [] -> { 976 - let pairs = 977 - env.vars 978 - |> dict.to_list 979 - |> list.map(fn(pair) { 980 - let #(k, v) = pair 981 - Record([ 982 - #("name", String(k)), 983 - #("value", String(value.as_string(v))), 984 - ]) 985 - }) 986 - ok(env, value.table_from_records(pairs)) 981 + // Process environment (same data as `$env`), as a name/value table 982 + case env.env_record(env) { 983 + Record(fields) -> { 984 + let pairs = 985 + list.map(fields, fn(pair) { 986 + let #(k, v) = pair 987 + Record([ 988 + #("name", String(k)), 989 + #("value", String(value.as_string(v))), 990 + ]) 991 + }) 992 + ok(env, value.table_from_records(pairs)) 993 + } 994 + other -> ok(env, other) 995 + } 987 996 } 988 - _ -> err(env, "env: unexpected args") 997 + _ -> err(env, "env: unexpected args (use `env` or `env NAME`)") 989 998 } 990 999 } 991 1000
+85
src/gleshell/color.gleam
··· 41 41 /// Bold — emphasis (prompt name). 42 42 const bold = "\u{001b}[1m" 43 43 44 + /// Yellow — operators (Nu `shape_operator`). 45 + const yellow = "\u{001b}[33m" 46 + 47 + /// Bold cyan — internal commands / lists / records (Nu `shape_internalcall`). 48 + const bold_cyan = "\u{001b}[1;36m" 49 + 50 + /// Bold blue — flags (Nu `shape_flag`). 51 + const bold_blue = "\u{001b}[1;34m" 52 + 53 + /// Bold purple — ints, floats, pipes (Nu `shape_int` / `shape_pipe`). 54 + const bold_purple = "\u{001b}[1;35m" 55 + 56 + /// White on red — garbage / lex errors (Nu `shape_garbage`). 57 + const garbage = "\u{001b}[1;37;41m" 58 + 44 59 /// Whether ANSI color should be emitted. 45 60 /// 46 61 /// - Off when `NO_COLOR` is set to a non-empty value (https://no-color.org). ··· 163 178 164 179 pub fn prompt_mark(on: Bool, text: String) -> String { 165 180 paint(on, bold, text) 181 + } 182 + 183 + // --- syntax shapes (Nushell `shape_*` defaults) --- 184 + 185 + pub fn shape_internalcall(on: Bool, text: String) -> String { 186 + paint(on, bold_cyan, text) 187 + } 188 + 189 + pub fn shape_external(on: Bool, text: String) -> String { 190 + paint(on, cyan, text) 191 + } 192 + 193 + pub fn shape_externalarg(on: Bool, text: String) -> String { 194 + paint(on, bold_green, text) 195 + } 196 + 197 + pub fn shape_string(on: Bool, text: String) -> String { 198 + paint(on, green, text) 199 + } 200 + 201 + pub fn shape_int(on: Bool, text: String) -> String { 202 + paint(on, bold_purple, text) 203 + } 204 + 205 + pub fn shape_float(on: Bool, text: String) -> String { 206 + paint(on, bold_purple, text) 207 + } 208 + 209 + pub fn shape_bool(on: Bool, text: String) -> String { 210 + paint(on, light_cyan, text) 211 + } 212 + 213 + pub fn shape_nothing(on: Bool, text: String) -> String { 214 + paint(on, light_cyan, text) 215 + } 216 + 217 + pub fn shape_flag(on: Bool, text: String) -> String { 218 + paint(on, bold_blue, text) 219 + } 220 + 221 + pub fn shape_pipe(on: Bool, text: String) -> String { 222 + paint(on, bold_purple, text) 223 + } 224 + 225 + pub fn shape_operator(on: Bool, text: String) -> String { 226 + paint(on, yellow, text) 227 + } 228 + 229 + pub fn shape_variable(on: Bool, text: String) -> String { 230 + paint(on, purple, text) 231 + } 232 + 233 + pub fn shape_list(on: Bool, text: String) -> String { 234 + paint(on, bold_cyan, text) 235 + } 236 + 237 + pub fn shape_record(on: Bool, text: String) -> String { 238 + paint(on, bold_cyan, text) 239 + } 240 + 241 + pub fn shape_comment(on: Bool, text: String) -> String { 242 + paint(on, dark_gray, text) 243 + } 244 + 245 + pub fn shape_keyword(on: Bool, text: String) -> String { 246 + paint(on, bold_cyan, text) 247 + } 248 + 249 + pub fn shape_garbage(on: Bool, text: String) -> String { 250 + paint(on, garbage, text) 166 251 } 167 252 168 253 /// Visible length ignoring ANSI CSI sequences (`ESC [ … final`).
+69 -7
src/gleshell/env.gleam
··· 1 1 //// Shell environment: cwd, variables, last exit status. 2 + //// 3 + //// Nushell-style process env lives under `$env` / `$env.VAR`. 2 4 3 5 import gleam/dict.{type Dict} 6 + import gleam/list 7 + import gleam/string 4 8 import gleshell/sys 5 - import gleshell/value.{type Value, Nothing, String} 9 + import gleshell/value.{type Value, Nothing, Record, String} 6 10 7 11 pub type Env { 8 12 Env(cwd: String, vars: Dict(String, Value), last_exit: Int) ··· 24 28 Ok(v) -> v 25 29 Error(Nil) -> Nothing 26 30 } 31 + // `$env` — full process environment as a record 32 + "env" -> env_record(env) 27 33 _ -> 28 - case dict.get(env.vars, name) { 29 - Ok(v) -> v 30 - Error(Nil) -> 31 - case sys.getenv(name) { 32 - Ok(s) -> String(s) 33 - Error(Nil) -> Nothing 34 + case string.starts_with(name, "env.") { 35 + // `$env.VAR` — one OS environment variable 36 + True -> get_os_env(env, string.drop_start(name, 4)) 37 + False -> 38 + case dict.get(env.vars, name) { 39 + Ok(v) -> v 40 + Error(Nil) -> 41 + case sys.getenv(name) { 42 + Ok(s) -> String(s) 43 + Error(Nil) -> Nothing 44 + } 34 45 } 35 46 } 36 47 } 37 48 } 38 49 50 + /// Process environment as a record (Nushell `$env`). 51 + /// `PWD` always reflects the shell cwd. 52 + pub fn env_record(env: Env) -> Value { 53 + let pairs = 54 + sys.list_env() 55 + |> list.map(fn(pair) { 56 + let #(k, v) = pair 57 + case k { 58 + "PWD" -> #("PWD", String(env.cwd)) 59 + _ -> #(k, String(v)) 60 + } 61 + }) 62 + let has_pwd = 63 + list.any(pairs, fn(pair) { 64 + let #(k, _) = pair 65 + k == "PWD" 66 + }) 67 + let pairs = case has_pwd { 68 + True -> pairs 69 + False -> list.append(pairs, [#("PWD", String(env.cwd))]) 70 + } 71 + Record(pairs) 72 + } 73 + 74 + fn get_os_env(env: Env, key: String) -> Value { 75 + case key { 76 + "" -> Nothing 77 + "PWD" | "pwd" -> String(env.cwd) 78 + _ -> 79 + case sys.getenv(key) { 80 + Ok(s) -> String(s) 81 + Error(Nil) -> Nothing 82 + } 83 + } 84 + } 85 + 39 86 pub fn set_var(env: Env, name: String, value: Value) -> Env { 40 87 Env(..env, vars: dict.insert(env.vars, name, value)) 41 88 } 42 89 90 + /// Set a process environment variable (`$env.NAME = …`). 91 + /// Setting `PWD` changes the shell working directory. 92 + pub fn set_os_env(env: Env, name: String, value: Value) -> Result(Env, String) { 93 + case name { 94 + "" -> Error("empty environment variable name") 95 + "PWD" | "pwd" -> set_cwd(env, value.as_string(value)) 96 + _ -> { 97 + let _ = sys.setenv(name, value.as_string(value)) 98 + Ok(env) 99 + } 100 + } 101 + } 102 + 43 103 pub fn set_input(env: Env, input: Value) -> Env { 44 104 set_var(env, "in", input) 45 105 } ··· 51 111 Ok(c) -> c 52 112 Error(_) -> path 53 113 } 114 + // Keep process PWD in sync (Nushell does this for `$env.PWD`) 115 + let _ = sys.setenv("PWD", cwd) 54 116 Ok(Env(..env, cwd: cwd)) 55 117 } 56 118 Error(e) -> Error(e)
+32 -3
src/gleshell/eval.gleam
··· 7 7 import gleshell/builtins 8 8 import gleshell/env.{type Env} 9 9 import gleshell/parser.{ 10 - type Arg, type Command, type Expr, type Pipeline, type Statement, FlagArg, Let, 11 - ListExpr, Lit, RecordExpr, ValueArg, Var, 10 + type Arg, type Command, type Expr, type Pipeline, type Statement, EnvAssign, 11 + FlagArg, Let, ListExpr, Lit, RecordExpr, ValueArg, Var, 12 12 } 13 13 import gleshell/sys 14 14 import gleshell/value.{type Value, Fail, List, Nothing, Record, String} ··· 19 19 } 20 20 21 21 pub fn eval_source(env: Env, source: String) -> EvalResult { 22 + // Fresh statement — do not inherit a prior external command's TTY-shown flag. 23 + sys.clear_output_shown() 22 24 let source = string.trim(source) 23 25 case source { 24 26 "" -> Continue(env, Nothing) ··· 42 44 } 43 45 } 44 46 } 47 + EnvAssign(name, pipeline) -> 48 + case eval_pipeline(env, pipeline, Nothing) { 49 + Quit(code) -> Quit(code) 50 + Continue(env2, value) -> 51 + case value { 52 + Fail(_) -> Continue(env2, value) 53 + _ -> 54 + case env.set_os_env(env2, name, value) { 55 + Ok(env3) -> Continue(env.set_exit(env3, 0), value) 56 + Error(msg) -> 57 + Continue(env.set_exit(env2, 1), Fail(msg)) 58 + } 59 + } 60 + } 45 61 parser.Expr(pipeline) -> eval_pipeline(env, pipeline, Nothing) 46 62 } 47 63 } ··· 64 80 65 81 fn eval_command(env: Env, cmd: Command, input: Value) -> EvalResult { 66 82 case cmd { 83 + // Bare value stage produced by the parser for `$env`, `$x`, literals, … 84 + parser.Command("__value__", [ValueArg(expr)], False) -> { 85 + sys.clear_output_shown() 86 + let env = env.set_input(env, input) 87 + case eval_expr(env, expr) { 88 + Ok(v) -> Continue(env.set_exit(env, 0), v) 89 + Error(msg) -> Continue(env.set_exit(env, 1), Fail(msg)) 90 + } 91 + } 67 92 parser.Command(name, args, external) -> { 68 93 let env = env.set_input(env, input) 69 94 case eval_args(env, args) { ··· 71 96 Ok(#(pos, flags)) -> { 72 97 case external { 73 98 True -> run_external(env, name, pos) 74 - False -> 99 + False -> { 100 + // Builtins produce a new value that was not streamed to the TTY. 101 + sys.clear_output_shown() 75 102 case resolve_builtin(name, pos) { 76 103 Ok(#(builtin, pos2)) -> 77 104 case builtin(env, input, pos2, flags) { ··· 84 111 Continue(env2, value) 85 112 } 86 113 } 114 + // Unknown name → external binary (may set output_shown). 87 115 Error(Nil) -> run_external(env, name, pos) 88 116 } 117 + } 89 118 } 90 119 } 91 120 }
+505
src/gleshell/highlight.gleam
··· 1 + //// Live syntax highlighting for the REPL input line (Nushell-style shapes). 2 + 3 + import gleam/list 4 + import gleam/string 5 + import gleshell/builtins 6 + import gleshell/color 7 + 8 + /// Colorize a (possibly incomplete) input line for the line editor. 9 + /// Safe to call on every keystroke; never fails. 10 + pub fn line(source: String) -> String { 11 + highlight(color.enabled(), source) 12 + } 13 + 14 + /// Colorize with an explicit on/off switch (tests / `NO_COLOR`). 15 + pub fn highlight(on: Bool, source: String) -> String { 16 + case on { 17 + False -> source 18 + True -> { 19 + let builtins = builtins.names() 20 + paint_chars(string.to_graphemes(source), ExpectCommand, builtins, "") 21 + } 22 + } 23 + } 24 + 25 + type Expect { 26 + ExpectCommand 27 + ExpectArg 28 + } 29 + 30 + fn paint_chars( 31 + chars: List(String), 32 + expect: Expect, 33 + builtins: List(String), 34 + acc: String, 35 + ) -> String { 36 + case chars { 37 + [] -> acc 38 + [" ", ..rest] -> paint_chars(rest, expect, builtins, acc <> " ") 39 + ["\t", ..rest] -> paint_chars(rest, expect, builtins, acc <> "\t") 40 + ["\r", ..rest] | ["\n", ..rest] -> paint_chars(rest, expect, builtins, acc) 41 + ["#", ..rest] -> { 42 + let text = "#" <> string.concat(rest) 43 + acc <> color.shape_comment(True, text) 44 + } 45 + ["|", ..rest] -> 46 + paint_chars( 47 + rest, 48 + ExpectCommand, 49 + builtins, 50 + acc <> color.shape_pipe(True, "|"), 51 + ) 52 + ["^", ..rest] -> 53 + // Force external: next command word is external 54 + paint_after_external_mark(rest, builtins, acc <> color.shape_operator(True, "^")) 55 + ["$", ..rest] -> paint_variable(rest, expect, builtins, acc) 56 + ["\"", ..rest] -> paint_dq_string(rest, expect, builtins, acc) 57 + ["'", ..rest] -> paint_sq_string(rest, expect, builtins, acc) 58 + ["[", ..rest] -> 59 + paint_chars( 60 + rest, 61 + ExpectArg, 62 + builtins, 63 + acc <> color.shape_list(True, "["), 64 + ) 65 + ["]", ..rest] -> 66 + paint_chars( 67 + rest, 68 + ExpectArg, 69 + builtins, 70 + acc <> color.shape_list(True, "]"), 71 + ) 72 + ["{", ..rest] -> 73 + paint_chars( 74 + rest, 75 + ExpectArg, 76 + builtins, 77 + acc <> color.shape_record(True, "{"), 78 + ) 79 + ["}", ..rest] -> 80 + paint_chars( 81 + rest, 82 + ExpectArg, 83 + builtins, 84 + acc <> color.shape_record(True, "}"), 85 + ) 86 + ["(", ..rest] -> 87 + paint_chars( 88 + rest, 89 + ExpectArg, 90 + builtins, 91 + acc <> color.shape_operator(True, "("), 92 + ) 93 + [")", ..rest] -> 94 + paint_chars( 95 + rest, 96 + ExpectArg, 97 + builtins, 98 + acc <> color.shape_operator(True, ")"), 99 + ) 100 + [":", ..rest] -> 101 + paint_chars( 102 + rest, 103 + ExpectArg, 104 + builtins, 105 + acc <> color.shape_operator(True, ":"), 106 + ) 107 + [",", ..rest] -> 108 + paint_chars( 109 + rest, 110 + ExpectArg, 111 + builtins, 112 + acc <> color.shape_operator(True, ","), 113 + ) 114 + ["!", "=", ..rest] -> 115 + paint_chars( 116 + rest, 117 + ExpectArg, 118 + builtins, 119 + acc <> color.shape_operator(True, "!="), 120 + ) 121 + [">", "=", ..rest] -> 122 + paint_chars( 123 + rest, 124 + ExpectArg, 125 + builtins, 126 + acc <> color.shape_operator(True, ">="), 127 + ) 128 + ["<", "=", ..rest] -> 129 + paint_chars( 130 + rest, 131 + ExpectArg, 132 + builtins, 133 + acc <> color.shape_operator(True, "<="), 134 + ) 135 + ["=", "=", ..rest] -> 136 + paint_chars( 137 + rest, 138 + ExpectArg, 139 + builtins, 140 + acc <> color.shape_operator(True, "=="), 141 + ) 142 + ["=", ..rest] -> 143 + paint_chars( 144 + rest, 145 + ExpectArg, 146 + builtins, 147 + acc <> color.shape_operator(True, "="), 148 + ) 149 + [">", ..rest] -> 150 + paint_chars( 151 + rest, 152 + ExpectArg, 153 + builtins, 154 + acc <> color.shape_operator(True, ">"), 155 + ) 156 + ["<", ..rest] -> 157 + paint_chars( 158 + rest, 159 + ExpectArg, 160 + builtins, 161 + acc <> color.shape_operator(True, "<"), 162 + ) 163 + ["-", "-", ..rest] -> paint_flag(rest, "--", expect, builtins, acc) 164 + ["-", d, ..rest] -> 165 + case is_digit(d) { 166 + True -> paint_number(["-", d, ..rest], expect, builtins, acc) 167 + False -> paint_flag([d, ..rest], "-", expect, builtins, acc) 168 + } 169 + [c, ..] -> 170 + case is_digit(c) { 171 + True -> paint_number(chars, expect, builtins, acc) 172 + False -> 173 + case is_ident_start(c) { 174 + True -> paint_ident(chars, expect, builtins, acc) 175 + False -> 176 + // Unknown char — mark as garbage and continue 177 + paint_chars( 178 + list.drop(chars, 1), 179 + expect, 180 + builtins, 181 + acc <> color.shape_garbage(True, c), 182 + ) 183 + } 184 + } 185 + } 186 + } 187 + 188 + fn paint_after_external_mark( 189 + chars: List(String), 190 + builtins: List(String), 191 + acc: String, 192 + ) -> String { 193 + case chars { 194 + [" ", ..rest] -> paint_after_external_mark(rest, builtins, acc <> " ") 195 + ["\t", ..rest] -> paint_after_external_mark(rest, builtins, acc <> "\t") 196 + [] -> acc 197 + _ -> { 198 + let #(word, after) = take_ident(chars) 199 + case word { 200 + "" -> paint_chars(chars, ExpectArg, builtins, acc) 201 + w -> 202 + paint_chars( 203 + after, 204 + ExpectArg, 205 + builtins, 206 + acc <> color.shape_external(True, w), 207 + ) 208 + } 209 + } 210 + } 211 + } 212 + 213 + fn paint_variable( 214 + chars: List(String), 215 + expect: Expect, 216 + builtins: List(String), 217 + acc: String, 218 + ) -> String { 219 + let #(name, after) = take_ident(chars) 220 + let painted = color.shape_variable(True, "$" <> name) 221 + paint_chars(after, next_expect(expect), builtins, acc <> painted) 222 + } 223 + 224 + fn paint_dq_string( 225 + chars: List(String), 226 + expect: Expect, 227 + builtins: List(String), 228 + acc: String, 229 + ) -> String { 230 + let #(body, after, closed) = take_dq_body(chars, "") 231 + let painted = case closed { 232 + True -> color.shape_string(True, "\"" <> body <> "\"") 233 + False -> color.shape_string(True, "\"" <> body) 234 + } 235 + paint_chars(after, next_expect(expect), builtins, acc <> painted) 236 + } 237 + 238 + fn take_dq_body( 239 + chars: List(String), 240 + acc: String, 241 + ) -> #(String, List(String), Bool) { 242 + case chars { 243 + [] -> #(acc, [], False) 244 + ["\"", ..rest] -> #(acc, rest, True) 245 + ["\\", c, ..rest] -> take_dq_body(rest, acc <> "\\" <> c) 246 + ["\\"] -> #(acc <> "\\", [], False) 247 + [c, ..rest] -> take_dq_body(rest, acc <> c) 248 + } 249 + } 250 + 251 + fn paint_sq_string( 252 + chars: List(String), 253 + expect: Expect, 254 + builtins: List(String), 255 + acc: String, 256 + ) -> String { 257 + let #(body, after, closed) = take_sq_body(chars, "") 258 + let painted = case closed { 259 + True -> color.shape_string(True, "'" <> body <> "'") 260 + False -> color.shape_string(True, "'" <> body) 261 + } 262 + paint_chars(after, next_expect(expect), builtins, acc <> painted) 263 + } 264 + 265 + fn take_sq_body( 266 + chars: List(String), 267 + acc: String, 268 + ) -> #(String, List(String), Bool) { 269 + case chars { 270 + [] -> #(acc, [], False) 271 + ["'", ..rest] -> #(acc, rest, True) 272 + ["\\", c, ..rest] -> take_sq_body(rest, acc <> "\\" <> c) 273 + ["\\"] -> #(acc <> "\\", [], False) 274 + [c, ..rest] -> take_sq_body(rest, acc <> c) 275 + } 276 + } 277 + 278 + fn paint_flag( 279 + chars: List(String), 280 + prefix: String, 281 + expect: Expect, 282 + builtins: List(String), 283 + acc: String, 284 + ) -> String { 285 + let #(name, after) = take_ident(chars) 286 + let painted = color.shape_flag(True, prefix <> name) 287 + paint_chars(after, next_expect(expect), builtins, acc <> painted) 288 + } 289 + 290 + fn paint_number( 291 + chars: List(String), 292 + expect: Expect, 293 + builtins: List(String), 294 + acc: String, 295 + ) -> String { 296 + let #(num, after, is_float) = take_number(chars) 297 + let painted = case is_float { 298 + True -> color.shape_float(True, num) 299 + False -> color.shape_int(True, num) 300 + } 301 + paint_chars(after, next_expect(expect), builtins, acc <> painted) 302 + } 303 + 304 + fn paint_ident( 305 + chars: List(String), 306 + expect: Expect, 307 + builtins: List(String), 308 + acc: String, 309 + ) -> String { 310 + let #(word, after) = take_ident(chars) 311 + case word { 312 + "true" | "True" | "false" | "False" -> 313 + paint_chars( 314 + after, 315 + next_expect(expect), 316 + builtins, 317 + acc <> color.shape_bool(True, word), 318 + ) 319 + "null" | "nothing" | "Nothing" -> 320 + paint_chars( 321 + after, 322 + next_expect(expect), 323 + builtins, 324 + acc <> color.shape_nothing(True, word), 325 + ) 326 + "let" -> 327 + case expect { 328 + ExpectCommand -> 329 + paint_chars( 330 + after, 331 + ExpectArg, 332 + builtins, 333 + acc <> color.shape_keyword(True, word), 334 + ) 335 + ExpectArg -> 336 + paint_chars( 337 + after, 338 + ExpectArg, 339 + builtins, 340 + acc <> color.shape_externalarg(True, word), 341 + ) 342 + } 343 + _ -> 344 + case expect { 345 + ExpectCommand -> paint_command(word, after, builtins, acc) 346 + ExpectArg -> 347 + paint_chars( 348 + after, 349 + ExpectArg, 350 + builtins, 351 + acc <> color.shape_externalarg(True, word), 352 + ) 353 + } 354 + } 355 + } 356 + 357 + fn paint_command( 358 + word: String, 359 + after: List(String), 360 + builtins: List(String), 361 + acc: String, 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 + let painted = case list.contains(builtins, word) { 392 + True -> color.shape_internalcall(True, word) 393 + False -> color.shape_external(True, word) 394 + } 395 + paint_chars(after, ExpectArg, builtins, acc <> painted) 396 + } 397 + 398 + fn next_expect(expect: Expect) -> Expect { 399 + case expect { 400 + ExpectCommand -> ExpectArg 401 + 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 + } 415 + } 416 + 417 + fn take_ident(chars: List(String)) -> #(String, List(String)) { 418 + take_ident_loop(chars, "") 419 + } 420 + 421 + fn take_ident_loop(chars: List(String), acc: String) -> #(String, List(String)) { 422 + case chars { 423 + [c, ..rest] -> 424 + case is_ident_continue(c) { 425 + True -> take_ident_loop(rest, acc <> c) 426 + False -> #(acc, chars) 427 + } 428 + [] -> #(acc, []) 429 + } 430 + } 431 + 432 + fn take_number(chars: List(String)) -> #(String, List(String), Bool) { 433 + case chars { 434 + ["-", ..rest] -> take_number_loop(rest, "-", False) 435 + _ -> take_number_loop(chars, "", False) 436 + } 437 + } 438 + 439 + fn take_number_loop( 440 + chars: List(String), 441 + acc: String, 442 + seen_dot: Bool, 443 + ) -> #(String, List(String), Bool) { 444 + case chars { 445 + [c, ..rest] -> 446 + case is_digit(c) { 447 + True -> take_number_loop(rest, acc <> c, seen_dot) 448 + False -> 449 + case c == "." && !seen_dot { 450 + True -> take_number_loop(rest, acc <> c, True) 451 + False -> #(acc, chars, seen_dot) 452 + } 453 + } 454 + [] -> #(acc, [], seen_dot) 455 + } 456 + } 457 + 458 + fn is_digit(c: String) -> Bool { 459 + case c { 460 + "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True 461 + _ -> False 462 + } 463 + } 464 + 465 + fn is_ident_start(c: String) -> Bool { 466 + case c { 467 + "_" -> True 468 + _ -> { 469 + let lower = string.lowercase(c) 470 + case lower { 471 + "a" 472 + | "b" 473 + | "c" 474 + | "d" 475 + | "e" 476 + | "f" 477 + | "g" 478 + | "h" 479 + | "i" 480 + | "j" 481 + | "k" 482 + | "l" 483 + | "m" 484 + | "n" 485 + | "o" 486 + | "p" 487 + | "q" 488 + | "r" 489 + | "s" 490 + | "t" 491 + | "u" 492 + | "v" 493 + | "w" 494 + | "x" 495 + | "y" 496 + | "z" -> True 497 + _ -> False 498 + } 499 + } 500 + } 501 + } 502 + 503 + fn is_ident_continue(c: String) -> Bool { 504 + is_ident_start(c) || is_digit(c) || c == "-" || c == "." || c == "/" 505 + }
+59
src/gleshell/parser.gleam
··· 17 17 pub type Statement { 18 18 /// `let name = pipeline` 19 19 Let(name: String, pipeline: Pipeline) 20 + /// `$env.NAME = pipeline` — set a process environment variable 21 + EnvAssign(name: String, pipeline: Pipeline) 20 22 /// Bare pipeline expression 21 23 Expr(pipeline: Pipeline) 22 24 } ··· 112 114 use #(pipe, rest2) <- result_try(parse_pipeline(rest)) 113 115 Ok(#(Let(name, pipe), rest2)) 114 116 } 117 + // `$env.NAME = …` (Nushell-style process env assignment) 118 + [Dollar, Ident(name), Assign, ..rest] -> 119 + case string.starts_with(name, "env.") { 120 + True -> { 121 + let key = string.drop_start(name, 4) 122 + case key { 123 + "" -> 124 + Error(ParseError( 125 + "expected environment variable name after $env.", 126 + )) 127 + _ -> { 128 + use #(pipe, rest2) <- result_try(parse_assign_rhs(rest)) 129 + Ok(#(EnvAssign(key, pipe), rest2)) 130 + } 131 + } 132 + } 133 + False -> 134 + Error(ParseError( 135 + "only `$env.NAME = …` assignment is supported (use `let name = …` for shell vars)", 136 + )) 137 + } 115 138 _ -> { 116 139 use #(pipe, rest) <- result_try(parse_pipeline(tokens)) 117 140 Ok(#(Expr(pipe), rest)) ··· 129 152 } 130 153 } 131 154 155 + /// RHS of `$env.NAME = …`: a single expression becomes a value stage so bare 156 + /// words are strings (`$env.FOO = hello`); otherwise a full pipeline 157 + /// (`$env.FOO = range 3`, `$env.FOO = echo hi`). 158 + fn parse_assign_rhs( 159 + tokens: List(Token), 160 + ) -> Result(#(Pipeline, List(Token)), ParseError) { 161 + case is_expr_start(tokens) { 162 + True -> 163 + case parse_expr(tokens) { 164 + Ok(#(expr, rest)) -> 165 + case rest { 166 + [] | [Eof] -> 167 + Ok(#( 168 + Pipeline([Command("__value__", [ValueArg(expr)], False)]), 169 + rest, 170 + )) 171 + _ -> parse_pipeline(tokens) 172 + } 173 + Error(_) -> parse_pipeline(tokens) 174 + } 175 + False -> parse_pipeline(tokens) 176 + } 177 + } 178 + 132 179 fn parse_pipeline( 133 180 tokens: List(Token), 134 181 ) -> Result(#(Pipeline, List(Token)), ParseError) { ··· 164 211 [StringLit(s), ..rest] -> { 165 212 use #(args, rest2) <- result_try(parse_args(rest, [])) 166 213 Ok(#(Command(s, args, False), rest2)) 214 + } 215 + // Bare value as pipeline stage: `$env`, `$x`, `[1 2]`, `{a: 1}`, … 216 + // Becomes internal `__value__` that yields the expression. 217 + [Dollar, ..] 218 + | [LBracket, ..] 219 + | [LBrace, ..] 220 + | [IntLit(_), ..] 221 + | [FloatLit(_), ..] 222 + | [BoolLit(_), ..] 223 + | [NothingLit, ..] -> { 224 + use #(expr, rest) <- result_try(parse_expr(tokens)) 225 + Ok(#(Command("__value__", [ValueArg(expr)], False), rest)) 167 226 } 168 227 [] | [Eof] -> Error(ParseError("expected command")) 169 228 _ -> Error(ParseError("expected command name"))
+20 -2
src/gleshell/sys.gleam
··· 3 3 @external(erlang, "gleshell_ffi", "get_line") 4 4 pub fn get_line(prompt: String) -> Result(String, String) 5 5 6 - /// Run `body` as the OTP interactive shell process so the REPL gets 7 - /// edlin line editing: history (up/down) and Ctrl+R reverse-i-search. 6 + /// Print a line to stdout. In raw TTY REPL mode, newlines become CRLF so 7 + /// multi-line values (tables, pretty JSON) do not staircase. 8 + @external(erlang, "gleshell_ffi", "println") 9 + pub fn println(text: String) -> Nil 10 + 11 + /// Run `body` as the interactive shell (raw TTY editor when possible). 12 + /// Ensures Erlang `+Bc` so Ctrl+C cancels the line instead of aborting the VM. 8 13 @external(erlang, "gleshell_ffi", "run_as_shell") 9 14 pub fn run_as_shell(body: fn() -> Nil) -> Nil 10 15 ··· 20 25 @external(erlang, "gleshell_ffi", "setenv") 21 26 pub fn setenv(name: String, value: String) -> Result(Nil, Nil) 22 27 28 + /// All process environment variables as `(name, value)` pairs. 29 + @external(erlang, "gleshell_ffi", "list_env") 30 + pub fn list_env() -> List(#(String, String)) 31 + 23 32 @external(erlang, "gleshell_ffi", "run_cmd") 24 33 pub fn run_cmd( 25 34 command: String, 26 35 args: List(String), 27 36 ) -> Result(#(Int, String), String) 37 + 38 + /// True if the last external command already streamed its output to the TTY 39 + /// (PTY relay for interactive tools like `run0`). Consumes the flag. 40 + @external(erlang, "gleshell_ffi", "take_output_shown") 41 + pub fn take_output_shown() -> Bool 42 + 43 + /// Clear the "output already shown" flag (e.g. after a builtin transforms data). 44 + @external(erlang, "gleshell_ffi", "clear_output_shown") 45 + pub fn clear_output_shown() -> Nil 28 46 29 47 @external(erlang, "gleshell_ffi", "which") 30 48 pub fn which(command: String) -> Result(String, Nil)
+1102 -46
src/gleshell_ffi.erl
··· 9 9 get_cwd/0, 10 10 getenv/1, 11 11 setenv/2, 12 + list_env/0, 12 13 run_cmd/2, 13 14 which/1, 14 15 home_dir/0, 15 - stdout_isatty/0 16 + stdout_isatty/0, 17 + println/1, 18 + take_output_shown/0, 19 + clear_output_shown/0 16 20 ]). 17 21 18 - %% Read a line with edlin history support. 19 - %% 20 - %% Use get_until (not get_line): since OTP 26, io:get_line/1 input is not 21 - %% reliably saved in the shell history buffer; get_until is. See OTP #6896 22 - %% and the custom-shell guide. 22 + -define(ESC, 16#1b). 23 + -define(CSI_CLEAR_EOL, "\e[K"). 24 + -define(HISTORY_MAX, 2000). 25 + 26 + %% --------------------------------------------------------------------------- 27 + %% Public: write a line (CRLF in raw TTY mode so multi-line output does not 28 + %% staircase — raw mode does not map LF → CR+LF the way cooked mode does). 29 + %% --------------------------------------------------------------------------- 30 + 31 + -spec println(binary()) -> nil. 32 + println(Text) when is_binary(Text) -> 33 + case get(gleshell_raw) of 34 + true -> 35 + io:put_chars([to_crlf(Text), <<"\r\n">>]), 36 + nil; 37 + _ -> 38 + io:put_chars([Text, $\n]), 39 + nil 40 + end. 41 + 42 + %% Normalize newlines to CRLF without turning existing \r\n into \r\r\n. 43 + to_crlf(Bin) when is_binary(Bin) -> 44 + B1 = binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]), 45 + B2 = binary:replace(B1, <<"\r">>, <<"\n">>, [global]), 46 + binary:replace(B2, <<"\n">>, <<"\r\n">>, [global]). 47 + 48 + %% --------------------------------------------------------------------------- 49 + %% Public: read a line (syntax-highlighted when raw TTY mode is active) 50 + %% --------------------------------------------------------------------------- 51 + 23 52 -spec get_line(binary()) -> {ok, binary()} | {error, binary()}. 24 53 get_line(Prompt) when is_binary(Prompt) -> 54 + case get(gleshell_raw) of 55 + true -> 56 + raw_get_line(Prompt); 57 + _ -> 58 + classic_get_line(Prompt) 59 + end. 60 + 61 + %% Edlin / get_until path (non-TTY or when raw mode unavailable). 62 + classic_get_line(Prompt) -> 25 63 PromptChars = unicode:characters_to_list(Prompt), 26 64 case io:request( 27 65 standard_io, ··· 29 67 ) of 30 68 eof -> 31 69 {error, <<"eof">>}; 70 + {error, interrupted} -> 71 + %% Ctrl+C while reading: cancel line, keep the REPL alive. 72 + {error, <<"interrupted">>}; 32 73 {error, _} -> 33 74 {error, <<"io_error">>}; 34 75 Line when is_list(Line); is_binary(Line) -> ··· 36 77 Stripped = string:trim(Bin, trailing, [$\n, $\r]), 37 78 {ok, Stripped}; 38 79 Other -> 39 - %% Unexpected shape from a custom/get_until callback. 40 80 try 41 81 Bin = unicode:characters_to_binary(Other), 42 82 Stripped = string:trim(Bin, trailing, [$\n, $\r]), ··· 47 87 end 48 88 end. 49 89 50 - %% get_until callback: edlin already gathers a full line (with editing / 51 - %% history navigation); accept it as done. Cont starts as []. 90 + %% get_until callback: edlin already gathers a full line. 52 91 -spec parse_line(term(), term()) -> 53 92 {done, eof | string(), list()} | {more, term()}. 54 93 parse_line(_Cont, eof) -> ··· 56 95 parse_line(_Cont, Chars) when is_list(Chars) -> 57 96 {done, Chars, []}. 58 97 59 - %% Run Fun as the OTP interactive shell process so edlin line editing 60 - %% works: up/down history, Ctrl+R reverse-i-search, word kill, etc. 61 - %% Gleam starts the VM with -noshell, so without this we only get dumb 62 - %% line input and no reverse search. 98 + %% --------------------------------------------------------------------------- 99 + %% Shell bootstrap: prefer OTP raw mode for live syntax highlighting. 100 + %% Falls back to edlin interactive shell when raw is unavailable. 101 + %% 102 + %% Ctrl+C: the BEAM default opens the BREAK menu (a = abort → process exit). 103 + %% Re-exec once with +Bc so Ctrl+C is delivered as a character to our editor 104 + %% (cancel line) instead of killing the shell. See erl(1) +B. 105 + %% --------------------------------------------------------------------------- 106 + 63 107 -spec run_as_shell(fun(() -> term())) -> nil. 64 108 run_as_shell(Fun) when is_function(Fun, 0) -> 65 - enable_shell_history(), 66 - Parent = self(), 67 - case try_start_interactive(Parent, Fun) of 68 - {ok, started} -> 109 + case ensure_plus_bc() of 110 + {parent, Port} -> 111 + %% Child owns the TTY; we only wait for its exit status. 69 112 receive 70 - {gleshell_shell_done, ok} -> 113 + {Port, {exit_status, Status}} -> 114 + erlang:halt(Status) 115 + end; 116 + child -> 117 + enable_shell_history(), 118 + case try_start_raw() of 119 + true -> 120 + put(gleshell_raw, true), 121 + load_line_history(), 122 + configure_line_editor(), 123 + try 124 + Fun() 125 + after 126 + save_line_history() 127 + end, 71 128 nil; 72 - {gleshell_shell_done, {error, Class, Reason, Stack}} -> 73 - erlang:raise(Class, Reason, Stack) 74 - end; 75 - {ok, direct} -> 76 - configure_line_editor(), 77 - Fun(), 78 - nil 129 + false -> 130 + erase(gleshell_raw), 131 + Parent = self(), 132 + case try_start_interactive(Parent, Fun) of 133 + {ok, started} -> 134 + receive 135 + {gleshell_shell_done, ok} -> 136 + nil; 137 + {gleshell_shell_done, {error, Class, Reason, Stack}} -> 138 + erlang:raise(Class, Reason, Stack) 139 + end; 140 + {ok, direct} -> 141 + configure_line_editor(), 142 + Fun(), 143 + nil 144 + end 145 + end 146 + end. 147 + 148 + %% Ensure the emulator was started with +Bc (Ctrl+C → char, not BREAK/abort). 149 + %% Returns `child` when this process should run the REPL, or `{parent, Port}` 150 + %% when we re-exec'd and should wait on the child. 151 + -spec ensure_plus_bc() -> child | {parent, port()}. 152 + ensure_plus_bc() -> 153 + case os:getenv("GLESHELL_PLUS_BC") of 154 + "1" -> 155 + child; 156 + _ -> 157 + case already_has_plus_bc() of 158 + true -> 159 + os:putenv("GLESHELL_PLUS_BC", "1"), 160 + child; 161 + false -> 162 + reexec_with_plus_bc() 163 + end 164 + end. 165 + 166 + already_has_plus_bc() -> 167 + lists:any( 168 + fun(Var) -> 169 + case os:getenv(Var) of 170 + false -> 171 + false; 172 + Flags -> 173 + string:find(Flags, "+Bc") =/= nomatch 174 + end 175 + end, 176 + ["ERL_AFLAGS", "ERL_FLAGS", "ERL_ZFLAGS"] 177 + ). 178 + 179 + reexec_with_plus_bc() -> 180 + os:putenv("GLESHELL_PLUS_BC", "1"), 181 + case os:getenv("ERL_AFLAGS") of 182 + false -> 183 + os:putenv("ERL_AFLAGS", "+Bc"); 184 + Flags -> 185 + case string:find(Flags, "+Bc") of 186 + nomatch -> 187 + os:putenv("ERL_AFLAGS", "+Bc " ++ Flags); 188 + _ -> 189 + ok 190 + end 191 + end, 192 + case os:find_executable("erl") of 193 + false -> 194 + %% No erl on PATH — continue without +Bc (BREAK menu may appear). 195 + child; 196 + Erl -> 197 + Pa = lists:flatmap(fun(D) -> ["-pa", D] end, code:get_path()), 198 + Extra = init:get_plain_arguments(), 199 + Args = 200 + ["+Bc", "-noshell"] ++ 201 + Pa ++ 202 + ["-eval", "gleshell@@main:run(gleshell)", "-extra" | Extra], 203 + try 204 + Port = open_port( 205 + {spawn_executable, Erl}, 206 + [exit_status, nouse_stdio, {args, Args}] 207 + ), 208 + {parent, Port} 209 + catch 210 + _:_ -> 211 + child 212 + end 213 + end. 214 + 215 + try_start_raw() -> 216 + case stdout_isatty() of 217 + false -> 218 + false; 219 + true -> 220 + case catch shell:start_interactive({noshell, raw}) of 221 + ok -> 222 + true; 223 + {error, already_started} -> 224 + %% Cannot switch an existing interactive shell into raw. 225 + false; 226 + _ -> 227 + false 228 + end 79 229 end. 80 230 81 231 try_start_interactive(Parent, Fun) -> 82 - %% Empty slogan so we do not print the Erlang system banner. 83 232 _ = application:set_env(stdlib, shell_slogan, "", [{persistent, true}]), 84 233 case shell:start_interactive({gleshell_ffi, spawn_shell, [Parent, Fun]}) of 85 234 ok -> ··· 90 239 {ok, direct} 91 240 end. 92 241 93 - %% MFA entry for user_drv/group: must return the shell pid. Spawned 94 - %% under the group so group_leader is the edlin-enabled group. 95 242 -spec spawn_shell(pid(), fun(() -> term())) -> pid(). 96 243 spawn_shell(Parent, Fun) when is_pid(Parent), is_function(Fun, 0) -> 97 244 spawn(fun() -> ··· 101 248 of 102 249 _ -> 103 250 Parent ! {gleshell_shell_done, ok}, 104 - %% Intentional exit reason so user_drv does not print 105 - %% "Shell process terminated!" and restart us. 106 251 exit(die) 107 252 catch 108 253 Class:Reason:Stack -> ··· 111 256 end 112 257 end). 113 258 114 - %% Best-effort: unicode IO + save get_until lines into edlin history. 115 259 configure_line_editor() -> 116 260 _ = io:setopts([{encoding, unicode}, binary]), 117 261 try ··· 143 287 ok 144 288 end. 145 289 290 + %% --------------------------------------------------------------------------- 291 + %% Raw-mode line editor with Nushell-style syntax highlighting 292 + %% --------------------------------------------------------------------------- 293 + %% 294 + %% Buffer model: Left is graphemes before the cursor (reversed), 295 + %% Right is graphemes after the cursor (normal order). 296 + %% History is a list of binaries (newest first). 297 + 298 + raw_get_line(Prompt) when is_binary(Prompt) -> 299 + PromptList = unicode:characters_to_list(Prompt), 300 + History = case get(gleshell_history) of 301 + L when is_list(L) -> L; 302 + _ -> [] 303 + end, 304 + redraw(PromptList, [], []), 305 + raw_loop(PromptList, [], [], History, 0, <<>>). 306 + 307 + %% HistPos: 0 = editing current buffer; N>0 = viewing Nth history entry. 308 + %% Saved: buffer saved when first entering history navigation. 309 + raw_loop(Prompt, Left, Right, History, HistPos, Saved) -> 310 + case read_key() of 311 + eof -> 312 + io:put_chars("\r\n"), 313 + {error, <<"eof">>}; 314 + {error, _} -> 315 + io:put_chars("\r\n"), 316 + {error, <<"io_error">>}; 317 + enter -> 318 + Line = buffer_to_bin(Left, Right), 319 + io:put_chars("\r\n"), 320 + push_history(Line), 321 + {ok, Line}; 322 + {char, C} when is_integer(C), C >= 32, C =/= 127 -> 323 + %% Printable Unicode codepoint 324 + NewLeft = [C | Left], 325 + redraw(Prompt, NewLeft, Right), 326 + raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 327 + backspace -> 328 + case Left of 329 + [] -> 330 + raw_loop(Prompt, Left, Right, History, HistPos, Saved); 331 + [_ | Rest] -> 332 + redraw(Prompt, Rest, Right), 333 + raw_loop(Prompt, Rest, Right, History, 0, <<>>) 334 + end; 335 + delete -> 336 + case Right of 337 + [] -> 338 + raw_loop(Prompt, Left, Right, History, HistPos, Saved); 339 + [_ | Rest] -> 340 + redraw(Prompt, Left, Rest), 341 + raw_loop(Prompt, Left, Rest, History, 0, <<>>) 342 + end; 343 + left -> 344 + case Left of 345 + [] -> 346 + raw_loop(Prompt, Left, Right, History, HistPos, Saved); 347 + [C | Rest] -> 348 + redraw(Prompt, Rest, [C | Right]), 349 + raw_loop(Prompt, Rest, [C | Right], History, HistPos, Saved) 350 + end; 351 + right -> 352 + case Right of 353 + [] -> 354 + raw_loop(Prompt, Left, Right, History, HistPos, Saved); 355 + [C | Rest] -> 356 + redraw(Prompt, [C | Left], Rest), 357 + raw_loop(Prompt, [C | Left], Rest, History, HistPos, Saved) 358 + end; 359 + home -> 360 + NewRight = lists:reverse(Left) ++ Right, 361 + redraw(Prompt, [], NewRight), 362 + raw_loop(Prompt, [], NewRight, History, HistPos, Saved); 363 + 'end' -> 364 + NewLeft = lists:reverse(Right) ++ Left, 365 + redraw(Prompt, NewLeft, []), 366 + raw_loop(Prompt, NewLeft, [], History, HistPos, Saved); 367 + up -> 368 + hist_nav(Prompt, Left, Right, History, HistPos, Saved, 1); 369 + down -> 370 + hist_nav(Prompt, Left, Right, History, HistPos, Saved, -1); 371 + ctrl_a -> 372 + NewRight = lists:reverse(Left) ++ Right, 373 + redraw(Prompt, [], NewRight), 374 + raw_loop(Prompt, [], NewRight, History, HistPos, Saved); 375 + ctrl_e -> 376 + NewLeft = lists:reverse(Right) ++ Left, 377 + redraw(Prompt, NewLeft, []), 378 + raw_loop(Prompt, NewLeft, [], History, HistPos, Saved); 379 + ctrl_u -> 380 + redraw(Prompt, [], Right), 381 + raw_loop(Prompt, [], Right, History, 0, <<>>); 382 + ctrl_k -> 383 + redraw(Prompt, Left, []), 384 + raw_loop(Prompt, Left, [], History, 0, <<>>); 385 + ctrl_w -> 386 + {NewLeft, _} = kill_word(Left), 387 + redraw(Prompt, NewLeft, Right), 388 + raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 389 + ctrl_d -> 390 + case {Left, Right} of 391 + {[], []} -> 392 + io:put_chars("\r\n"), 393 + {error, <<"eof">>}; 394 + {_, []} -> 395 + raw_loop(Prompt, Left, Right, History, HistPos, Saved); 396 + {_, [_ | Rest]} -> 397 + redraw(Prompt, Left, Rest), 398 + raw_loop(Prompt, Left, Rest, History, 0, <<>>) 399 + end; 400 + ctrl_c -> 401 + %% Cancel current line (like bash) and return empty. 402 + io:put_chars("^C\r\n"), 403 + {ok, <<>>}; 404 + ctrl_l -> 405 + io:put_chars("\e[H\e[2J"), 406 + redraw(Prompt, Left, Right), 407 + raw_loop(Prompt, Left, Right, History, HistPos, Saved); 408 + ctrl_r -> 409 + reverse_search(Prompt, History); 410 + tab -> 411 + tab_complete(Prompt, Left, Right, History, HistPos, Saved); 412 + _Other -> 413 + raw_loop(Prompt, Left, Right, History, HistPos, Saved) 414 + end. 415 + 416 + %% --------------------------------------------------------------------------- 417 + %% Tab: filename completion (path under cursor) 418 + %% --------------------------------------------------------------------------- 419 + %% 420 + %% Completes the token before the cursor as a filesystem path. 421 + %% One match → insert it (directories get a trailing /). 422 + %% Several matches → extend the longest common prefix; if that does not 423 + %% advance the buffer, list candidates under the line and redraw. 424 + 425 + tab_complete(Prompt, Left, Right, History, HistPos, Saved) -> 426 + {PrefixRev, Word} = word_before_cursor(Left), 427 + case filename_completions(Word) of 428 + [] -> 429 + beep(), 430 + raw_loop(Prompt, Left, Right, History, HistPos, Saved); 431 + [Only] -> 432 + NewLeft = apply_completed_word(PrefixRev, Only), 433 + redraw(Prompt, NewLeft, Right), 434 + raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 435 + Matches -> 436 + Common = longest_common_prefix(Matches), 437 + case Common =/= Word andalso length(Common) >= length(Word) of 438 + true -> 439 + NewLeft = apply_completed_word(PrefixRev, Common), 440 + redraw(Prompt, NewLeft, Right), 441 + raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 442 + false -> 443 + show_completions(Matches), 444 + redraw(Prompt, Left, Right), 445 + raw_loop(Prompt, Left, Right, History, HistPos, Saved) 446 + end 447 + end. 448 + 449 + beep() -> 450 + io:put_chars([7]). 451 + 452 + %% Left is graphemes before the cursor in reverse order. 453 + %% Returns {PrefixRev, WordForward} where Word is the path token. 454 + word_before_cursor(Left) -> 455 + take_completion_word(Left, []). 456 + 457 + %% Acc: walking Left (reversed buffer) with [C|Acc] rebuilds the word forward. 458 + take_completion_word([], Acc) -> 459 + {[], Acc}; 460 + take_completion_word([C | Rest], Acc) -> 461 + case is_completion_break(C) of 462 + true -> 463 + {[C | Rest], Acc}; 464 + false -> 465 + take_completion_word(Rest, [C | Acc]) 466 + end. 467 + 468 + is_completion_break(C) when C =:= $\s; C =:= $\t -> 469 + true; 470 + is_completion_break(C) when C =:= $|; C =:= $;; C =:= $& -> 471 + true; 472 + is_completion_break(C) when C =:= $(; C =:= $); C =:= $[; C =:= $] -> 473 + true; 474 + is_completion_break(C) when C =:= ${; C =:= $}; C =:= $<; C =:= $> -> 475 + true; 476 + is_completion_break(C) when C =:= $'; C =:= $" -> 477 + true; 478 + is_completion_break(_) -> 479 + false. 480 + 481 + apply_completed_word(PrefixRev, Word) -> 482 + lists:reverse(Word) ++ PrefixRev. 483 + 484 + %% Return sorted completion strings (as typed, with ~ preserved; dirs end in /). 485 + filename_completions(Word) -> 486 + {ListDirTyped, Base, InsertPrefix} = split_completion_word(Word), 487 + ListDir = expand_home_path(ListDirTyped), 488 + case file:list_dir(ListDir) of 489 + {ok, Names0} -> 490 + Names = lists:sort(Names0), 491 + [ 492 + InsertPrefix ++ maybe_dir_slash(ListDir, Name) 493 + || Name <- Names, 494 + lists:prefix(Base, Name), 495 + show_dotfile(Base, Name) 496 + ]; 497 + {error, _} -> 498 + [] 499 + end. 500 + 501 + %% Hide dotfiles unless the partial name already starts with '.'. 502 + show_dotfile([$. | _], _) -> 503 + true; 504 + show_dotfile(_, [$. | _]) -> 505 + false; 506 + show_dotfile(_, _) -> 507 + true. 508 + 509 + maybe_dir_slash(ListDir, Name) -> 510 + case filelib:is_dir(filename:join(ListDir, Name)) of 511 + true -> Name ++ "/"; 512 + false -> Name 513 + end. 514 + 515 + %% Split a path word into {dir_to_list, basename_prefix, insert_prefix}. 516 + %% insert_prefix is the directory part as the user typed it (incl. trailing /). 517 + split_completion_word(Word) -> 518 + case rsplit_path(Word) of 519 + {none, Base} -> 520 + {".", Base, ""}; 521 + {Dir, Base} -> 522 + ListDir = 523 + case Dir of 524 + "" -> "/"; 525 + _ -> Dir 526 + end, 527 + InsertPrefix = 528 + case Dir of 529 + "" -> "/"; 530 + _ -> Dir ++ "/" 531 + end, 532 + {ListDir, Base, InsertPrefix} 533 + end. 534 + 535 + %% Rightmost / splits directory from the partial basename. 536 + rsplit_path(Word) -> 537 + rsplit_path(lists:reverse(Word), []). 538 + 539 + rsplit_path([], Acc) -> 540 + {none, Acc}; 541 + rsplit_path([$/ | Rest], Acc) -> 542 + {lists:reverse(Rest), Acc}; 543 + rsplit_path([C | Rest], Acc) -> 544 + rsplit_path(Rest, [C | Acc]). 545 + 546 + expand_home_path(Path) -> 547 + case Path of 548 + "~" -> 549 + home_path_string(); 550 + [$~, $/ | More] -> 551 + home_path_string() ++ "/" ++ More; 552 + _ -> 553 + Path 554 + end. 555 + 556 + home_path_string() -> 557 + case os:getenv("HOME") of 558 + false -> "."; 559 + Home when is_list(Home) -> Home; 560 + Home when is_binary(Home) -> unicode:characters_to_list(Home) 561 + end. 562 + 563 + longest_common_prefix([]) -> 564 + ""; 565 + longest_common_prefix([H | T]) -> 566 + lists:foldl(fun lcp2/2, H, T). 567 + 568 + lcp2(A, B) -> 569 + lcp2(A, B, []). 570 + 571 + lcp2([X | As], [X | Bs], Acc) -> 572 + lcp2(As, Bs, [X | Acc]); 573 + lcp2(_, _, Acc) -> 574 + lists:reverse(Acc). 575 + 576 + show_completions(Matches) -> 577 + io:put_chars("\r\n"), 578 + case io:columns() of 579 + {ok, Cols} when is_integer(Cols), Cols > 8 -> 580 + print_completion_columns(Matches, Cols); 581 + _ -> 582 + io:put_chars(lists:join(" ", Matches)), 583 + io:put_chars("\r\n") 584 + end. 585 + 586 + print_completion_columns(Matches, Cols) -> 587 + MaxLen = lists:max([0 | [length(M) || M <- Matches]]), 588 + Width = MaxLen + 2, 589 + PerRow = max(1, Cols div Width), 590 + print_rows(Matches, PerRow, Width). 591 + 592 + print_rows([], _PerRow, _Width) -> 593 + ok; 594 + print_rows(Matches, PerRow, Width) -> 595 + {Row, Rest} = take_n(Matches, PerRow, []), 596 + Line = [ 597 + pad_cell(M, Width) 598 + || M <- Row 599 + ], 600 + io:put_chars([Line, "\r\n"]), 601 + print_rows(Rest, PerRow, Width). 602 + 603 + take_n(List, 0, Acc) -> 604 + {lists:reverse(Acc), List}; 605 + take_n([], _N, Acc) -> 606 + {lists:reverse(Acc), []}; 607 + take_n([H | T], N, Acc) -> 608 + take_n(T, N - 1, [H | Acc]). 609 + 610 + pad_cell(S, Width) -> 611 + Pad = Width - length(S), 612 + case Pad > 0 of 613 + true -> S ++ lists:duplicate(Pad, $\s); 614 + false -> S ++ " " 615 + end. 616 + 617 + hist_nav(Prompt, Left, Right, History, HistPos, Saved, Delta) -> 618 + NewPos = HistPos + Delta, 619 + Len = length(History), 620 + if 621 + NewPos < 0 -> 622 + raw_loop(Prompt, Left, Right, History, HistPos, Saved); 623 + NewPos =:= 0 -> 624 + %% Restore saved draft 625 + {L, R} = bin_to_buffer(Saved), 626 + redraw(Prompt, L, R), 627 + raw_loop(Prompt, L, R, History, 0, <<>>); 628 + NewPos > Len -> 629 + raw_loop(Prompt, Left, Right, History, HistPos, Saved); 630 + true -> 631 + NewSaved = case HistPos of 632 + 0 -> buffer_to_bin(Left, Right); 633 + _ -> Saved 634 + end, 635 + Entry = lists:nth(NewPos, History), 636 + {L, R} = bin_to_buffer(Entry), 637 + redraw(Prompt, L, R), 638 + raw_loop(Prompt, L, R, History, NewPos, NewSaved) 639 + end. 640 + 641 + %% Minimal Ctrl+R reverse-i-search over history. 642 + reverse_search(Prompt, History) -> 643 + reverse_search_loop(Prompt, History, [], match_history(History, [])). 644 + 645 + reverse_search_loop(Prompt, History, Query, Match) -> 646 + Hint = unicode:characters_to_list( 647 + ["(reverse-i-search)`", Query, "': ", Match] 648 + ), 649 + io:put_chars([$\r, Hint, ?CSI_CLEAR_EOL]), 650 + case read_key() of 651 + eof -> 652 + io:put_chars("\r\n"), 653 + {error, <<"eof">>}; 654 + enter -> 655 + Line = iolist_to_binary(Match), 656 + io:put_chars("\r\n"), 657 + push_history(Line), 658 + {ok, Line}; 659 + ctrl_c -> 660 + io:put_chars("\r\n"), 661 + redraw(Prompt, [], []), 662 + raw_loop(Prompt, [], [], History, 0, <<>>); 663 + ctrl_g -> 664 + redraw(Prompt, [], []), 665 + raw_loop(Prompt, [], [], History, 0, <<>>); 666 + ctrl_r -> 667 + %% Find older match 668 + NewMatch = match_history_after(History, Query, Match), 669 + reverse_search_loop(Prompt, History, Query, NewMatch); 670 + backspace -> 671 + NewQuery = case Query of 672 + [] -> []; 673 + [_ | _] -> lists:droplast(Query) 674 + end, 675 + reverse_search_loop( 676 + Prompt, History, NewQuery, match_history(History, NewQuery) 677 + ); 678 + {char, C} when is_integer(C), C >= 32, C =/= 127 -> 679 + NewQuery = Query ++ [C], 680 + reverse_search_loop( 681 + Prompt, History, NewQuery, match_history(History, NewQuery) 682 + ); 683 + _ -> 684 + reverse_search_loop(Prompt, History, Query, Match) 685 + end. 686 + 687 + match_history(_History, []) -> 688 + ""; 689 + match_history(History, Query) -> 690 + QBin = unicode:characters_to_binary(Query), 691 + case first_match(History, QBin) of 692 + undefined -> ""; 693 + Bin -> unicode:characters_to_list(Bin) 694 + end. 695 + 696 + match_history_after(History, Query, CurrentMatch) -> 697 + QBin = unicode:characters_to_binary(Query), 698 + CurBin = unicode:characters_to_binary(CurrentMatch), 699 + case skip_until_then_match(History, CurBin, QBin, false) of 700 + undefined -> CurrentMatch; 701 + Bin -> unicode:characters_to_list(Bin) 702 + end. 703 + 704 + first_match([], _) -> 705 + undefined; 706 + first_match([H | T], Q) -> 707 + case binary:match(H, Q) of 708 + nomatch -> first_match(T, Q); 709 + _ -> H 710 + end. 711 + 712 + skip_until_then_match([], _Cur, _Q, _Seen) -> 713 + undefined; 714 + skip_until_then_match([H | T], Cur, Q, false) -> 715 + case H =:= Cur of 716 + true -> skip_until_then_match(T, Cur, Q, true); 717 + false -> skip_until_then_match(T, Cur, Q, false) 718 + end; 719 + skip_until_then_match([H | T], Cur, Q, true) -> 720 + case binary:match(H, Q) of 721 + nomatch -> skip_until_then_match(T, Cur, Q, true); 722 + _ -> H 723 + end. 724 + 725 + kill_word([]) -> 726 + {[], []}; 727 + kill_word(Left) -> 728 + %% Left is reversed: strip trailing spaces then a word. 729 + L1 = drop_while_space(Left), 730 + drop_while_word(L1). 731 + 732 + drop_while_space([C | Rest]) when C =:= $\s; C =:= $\t -> 733 + drop_while_space(Rest); 734 + drop_while_space(L) -> 735 + L. 736 + 737 + drop_while_word([]) -> 738 + {[], []}; 739 + drop_while_word([C | Rest]) when C =:= $\s; C =:= $\t -> 740 + {[C | Rest], []}; 741 + drop_while_word([_ | Rest]) -> 742 + drop_while_word(Rest). 743 + 744 + redraw(Prompt, Left, Right) -> 745 + Full = lists:reverse(Left) ++ Right, 746 + FullBin = unicode:characters_to_binary(Full), 747 + Colored = highlight_line(FullBin), 748 + io:put_chars([$\r, Prompt, Colored, ?CSI_CLEAR_EOL]), 749 + case length(Right) of 750 + 0 -> 751 + ok; 752 + N -> 753 + io:put_chars(["\e[", integer_to_list(N), $D]) 754 + end. 755 + 756 + highlight_line(Bin) when is_binary(Bin) -> 757 + case get(gleshell_color) of 758 + false -> 759 + Bin; 760 + _ -> 761 + try 762 + case 'gleshell@highlight':line(Bin) of 763 + Out when is_binary(Out) -> Out; 764 + Out when is_list(Out) -> unicode:characters_to_binary(Out); 765 + _ -> Bin 766 + end 767 + catch 768 + _:_ -> 769 + Bin 770 + end 771 + end. 772 + 773 + buffer_to_bin(Left, Right) -> 774 + unicode:characters_to_binary(lists:reverse(Left) ++ Right). 775 + 776 + bin_to_buffer(Bin) when is_binary(Bin) -> 777 + Chars = unicode:characters_to_list(Bin), 778 + {lists:reverse(Chars), []}; 779 + bin_to_buffer(List) when is_list(List) -> 780 + {lists:reverse(List), []}. 781 + 782 + %% --------------------------------------------------------------------------- 783 + %% Key reading (raw mode — keys arrive as soon as pressed) 784 + %% --------------------------------------------------------------------------- 785 + 786 + read_key() -> 787 + case io:get_chars("", 1) of 788 + eof -> 789 + eof; 790 + {error, Reason} -> 791 + {error, Reason}; 792 + <<C/utf8>> -> 793 + decode_key(C, <<>>); 794 + [C] when is_integer(C) -> 795 + decode_key(C, <<>>); 796 + Bin when is_binary(Bin), byte_size(Bin) > 0 -> 797 + case unicode:characters_to_list(Bin) of 798 + [C | _] -> decode_key(C, <<>>); 799 + _ -> read_key() 800 + end; 801 + List when is_list(List), List =/= [] -> 802 + decode_key(hd(List), <<>>); 803 + _ -> 804 + read_key() 805 + end. 806 + 807 + decode_key($\r, _) -> enter; 808 + decode_key($\n, _) -> enter; 809 + decode_key($\t, _) -> tab; 810 + decode_key(127, _) -> backspace; 811 + decode_key($\b, _) -> backspace; 812 + decode_key(1, _) -> ctrl_a; 813 + decode_key(5, _) -> ctrl_e; 814 + decode_key(4, _) -> ctrl_d; 815 + decode_key(3, _) -> ctrl_c; 816 + decode_key(11, _) -> ctrl_k; 817 + decode_key(21, _) -> ctrl_u; 818 + decode_key(23, _) -> ctrl_w; 819 + decode_key(12, _) -> ctrl_l; 820 + decode_key(18, _) -> ctrl_r; 821 + decode_key(?ESC, _) -> 822 + read_escape(); 823 + decode_key(C, _) when is_integer(C), C >= 32 -> 824 + {char, C}; 825 + decode_key(_, _) -> 826 + other. 827 + 828 + read_escape() -> 829 + case io:get_chars("", 1) of 830 + eof -> 831 + other; 832 + <<"[">> -> 833 + read_csi(); 834 + <<$O>> -> 835 + %% SS3 sequences: OH = home, OF = end, OA/OB/OC/OD arrows 836 + case io:get_chars("", 1) of 837 + <<"A">> -> up; 838 + <<"B">> -> down; 839 + <<"C">> -> right; 840 + <<"D">> -> left; 841 + <<"H">> -> home; 842 + <<"F">> -> 'end'; 843 + _ -> other 844 + end; 845 + _ -> 846 + other 847 + end. 848 + 849 + read_csi() -> 850 + read_csi_params([]). 851 + 852 + read_csi_params(Acc) -> 853 + case io:get_chars("", 1) of 854 + eof -> 855 + other; 856 + <<C/utf8>> when C >= $0, C =< $9 -> 857 + read_csi_params([C | Acc]); 858 + <<$;>> -> 859 + read_csi_params([$; | Acc]); 860 + <<$~>> -> 861 + Params = lists:reverse(Acc), 862 + case Params of 863 + "1" -> home; 864 + "3" -> delete; 865 + "4" -> 'end'; 866 + "7" -> home; 867 + "8" -> 'end'; 868 + _ -> other 869 + end; 870 + <<"A">> -> 871 + up; 872 + <<"B">> -> 873 + down; 874 + <<"C">> -> 875 + right; 876 + <<"D">> -> 877 + left; 878 + <<"H">> -> 879 + home; 880 + <<"F">> -> 881 + 'end'; 882 + _ -> 883 + other 884 + end. 885 + 886 + %% --------------------------------------------------------------------------- 887 + %% History persistence 888 + %% --------------------------------------------------------------------------- 889 + 890 + history_file() -> 891 + case application:get_env(kernel, shell_history_path) of 892 + {ok, Path} when is_list(Path) -> 893 + filename:join(Path, "lines"); 894 + {ok, Path} when is_binary(Path) -> 895 + filename:join(unicode:characters_to_list(Path), "lines"); 896 + _ -> 897 + filename:join( 898 + filename:basedir(user_cache, "gleshell-history"), "lines" 899 + ) 900 + end. 901 + 902 + load_line_history() -> 903 + File = history_file(), 904 + case file:read_file(File) of 905 + {ok, Bin} -> 906 + Lines = [ 907 + L 908 + || L <- binary:split(Bin, <<"\n">>, [global]), 909 + L =/= <<>> 910 + ], 911 + %% Newest first 912 + put(gleshell_history, lists:reverse(Lines)); 913 + _ -> 914 + put(gleshell_history, []) 915 + end, 916 + put(gleshell_color, true), 917 + ok. 918 + 919 + save_line_history() -> 920 + case get(gleshell_history) of 921 + Hist when is_list(Hist) -> 922 + File = history_file(), 923 + _ = filelib:ensure_dir(File), 924 + %% Store oldest-first for human readability 925 + Body = [[L, $\n] || L <- lists:reverse(lists:sublist(Hist, ?HISTORY_MAX))], 926 + _ = file:write_file(File, Body), 927 + ok; 928 + _ -> 929 + ok 930 + end. 931 + 932 + push_history(<<>>) -> 933 + ok; 934 + push_history(Line) when is_binary(Line) -> 935 + Hist = case get(gleshell_history) of 936 + L when is_list(L) -> L; 937 + _ -> [] 938 + end, 939 + New = case Hist of 940 + [Line | _] -> Hist; 941 + _ -> [Line | Hist] 942 + end, 943 + put(gleshell_history, lists:sublist(New, ?HISTORY_MAX)), 944 + ok. 945 + 946 + %% --------------------------------------------------------------------------- 947 + %% OS / process helpers 948 + %% --------------------------------------------------------------------------- 949 + 146 950 -spec set_cwd(binary()) -> {ok, nil} | {error, binary()}. 147 951 set_cwd(Path) when is_binary(Path) -> 148 952 case file:set_cwd(unicode:characters_to_list(Path)) of ··· 175 979 os:putenv(unicode:characters_to_list(Name), unicode:characters_to_list(Value)), 176 980 {ok, nil}. 177 981 982 + %% All process environment variables as a list of {Name, Value} binaries. 983 + -spec list_env() -> list({binary(), binary()}). 984 + list_env() -> 985 + lists:map( 986 + fun(Entry) -> 987 + case string:split(Entry, "=", leading) of 988 + [K, V] -> 989 + {unicode:characters_to_binary(K), unicode:characters_to_binary(V)}; 990 + [K] -> 991 + {unicode:characters_to_binary(K), <<>>}; 992 + _ -> 993 + {<<>>, <<>>} 994 + end 995 + end, 996 + os:getenv() 997 + ). 998 + 178 999 -spec which(binary()) -> {ok, binary()} | {error, nil}. 179 1000 which(Command) when is_binary(Command) -> 180 1001 case os:find_executable(unicode:characters_to_list(Command)) of ··· 193 1014 {ok, unicode:characters_to_binary(Home)} 194 1015 end. 195 1016 196 - %% True when stdout is a terminal (colors are useful). 197 1017 -spec stdout_isatty() -> boolean(). 198 1018 stdout_isatty() -> 199 1019 case io:columns() of ··· 211 1031 end 212 1032 end. 213 1033 214 - %% Run an executable with args; capture stdout+stderr and exit status. 215 - %% Returns {ok, {Status :: integer(), Output :: binary()}} | {error, binary()}. 1034 + %% --------------------------------------------------------------------------- 1035 + %% External commands 1036 + %% 1037 + %% Erlang's erl_child_setup detaches from the controlling TTY, so tools that 1038 + %% need interactive auth (run0, sudo, pkexec, polkit/pkttyagent) fail with: 1039 + %% "interactive authentication has not been enabled by the calling program" 1040 + %% 1041 + %% When a TTY is available and util-linux `script` is on PATH, we allocate a 1042 + %% PTY for the child and relay bytes between that session and the real 1043 + %% terminal so password prompts work. Output is still captured for pipelines. 1044 + %% --------------------------------------------------------------------------- 1045 + 216 1046 -spec run_cmd(binary(), [binary()]) -> {ok, {integer(), binary()}} | {error, binary()}. 217 1047 run_cmd(Command, Args) when is_binary(Command), is_list(Args) -> 218 1048 case os:find_executable(unicode:characters_to_list(Command)) of ··· 221 1051 Path -> 222 1052 PortArgs = [unicode:characters_to_list(A) || A <- Args], 223 1053 try 224 - Port = open_port( 225 - {spawn_executable, Path}, 226 - [ 227 - binary, 228 - exit_status, 229 - stderr_to_stdout, 230 - use_stdio, 231 - stream, 232 - {args, PortArgs} 233 - ] 234 - ), 235 - collect_output(Port, <<>>) 1054 + case {os:find_executable("script"), find_tty_path()} of 1055 + {Script, {ok, Tty}} when is_list(Script) -> 1056 + run_cmd_pty(Script, Path, PortArgs, Tty); 1057 + _ -> 1058 + run_cmd_direct(Path, PortArgs) 1059 + end 236 1060 catch 237 1061 _:Reason -> 238 1062 {error, reason_to_bin(Reason)} 239 1063 end 240 1064 end. 241 1065 1066 + run_cmd_direct(Path, PortArgs) -> 1067 + Port = open_port( 1068 + {spawn_executable, Path}, 1069 + [ 1070 + binary, 1071 + exit_status, 1072 + stderr_to_stdout, 1073 + use_stdio, 1074 + stream, 1075 + {args, PortArgs} 1076 + ] 1077 + ), 1078 + collect_output(Port, <<>>). 1079 + 1080 + %% Run via `script` so the child gets a controlling TTY (PTY), and relay I/O 1081 + %% with the real terminal for interactive authentication and prompts. 1082 + run_cmd_pty(Script, Path, PortArgs, TtyPath) -> 1083 + Port = open_port( 1084 + {spawn_executable, Script}, 1085 + [ 1086 + binary, 1087 + exit_status, 1088 + stderr_to_stdout, 1089 + use_stdio, 1090 + stream, 1091 + %% POSIX shell: caller's $SHELL may be nu/fish and break -c/--. 1092 + {env, [{"SHELL", "/bin/sh"}]}, 1093 + {args, ["-q", "-e", "/dev/null", "--", Path | PortArgs]} 1094 + ] 1095 + ), 1096 + case file:open(TtyPath, [write, raw, binary]) of 1097 + {ok, TtyOut} -> 1098 + %% Raw fds are process-local: reader opens its own handle. 1099 + Reader = spawn(fun() -> 1100 + case file:open(TtyPath, [read, raw, binary]) of 1101 + {ok, TtyIn} -> 1102 + tty_to_port(TtyIn, Port); 1103 + {error, _} -> 1104 + ok 1105 + end 1106 + end), 1107 + put(gleshell_output_shown, true), 1108 + try 1109 + collect_output_relay(Port, TtyOut, <<>>) 1110 + after 1111 + exit(Reader, kill), 1112 + catch file:close(TtyOut) 1113 + end; 1114 + {error, _} -> 1115 + %% Could not open the TTY for relay; still better than no PTY. 1116 + put(gleshell_output_shown, false), 1117 + collect_output(Port, <<>>) 1118 + end. 1119 + 1120 + tty_to_port(Tty, Port) -> 1121 + case file:read(Tty, 256) of 1122 + {ok, Data} when is_binary(Data), Data =/= <<>> -> 1123 + catch port_command(Port, Data), 1124 + tty_to_port(Tty, Port); 1125 + {ok, _} -> 1126 + tty_to_port(Tty, Port); 1127 + eof -> 1128 + ok; 1129 + {error, _} -> 1130 + ok 1131 + end. 1132 + 242 1133 collect_output(Port, Acc) -> 243 1134 receive 244 1135 {Port, {data, Data}} when is_binary(Data) -> ··· 251 1142 after 120_000 -> 252 1143 catch port_close(Port), 253 1144 {error, <<"command timed out after 120s">>} 1145 + end. 1146 + 1147 + collect_output_relay(Port, Tty, Acc) -> 1148 + receive 1149 + {Port, {data, Data}} when is_binary(Data) -> 1150 + _ = file:write(Tty, Data), 1151 + collect_output_relay(Port, Tty, <<Acc/binary, Data/binary>>); 1152 + {Port, {data, Data}} when is_list(Data) -> 1153 + Bin = unicode:characters_to_binary(Data), 1154 + _ = file:write(Tty, Bin), 1155 + collect_output_relay(Port, Tty, <<Acc/binary, Bin/binary>>); 1156 + {Port, {exit_status, Status}} -> 1157 + {ok, {Status, normalize_pty_output(Acc)}} 1158 + after 120_000 -> 1159 + catch port_close(Port), 1160 + {error, <<"command timed out after 120s">>} 1161 + end. 1162 + 1163 + %% PTY line discipline often emits CR-LF; normalize to LF for structured use. 1164 + normalize_pty_output(Bin) when is_binary(Bin) -> 1165 + binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]). 1166 + 1167 + %% True when the last external command already streamed output to the TTY 1168 + %% (PTY relay). Cleared after being read so the REPL does not double-print. 1169 + -spec take_output_shown() -> boolean(). 1170 + take_output_shown() -> 1171 + case erase(gleshell_output_shown) of 1172 + true -> true; 1173 + _ -> false 1174 + end. 1175 + 1176 + -spec clear_output_shown() -> nil. 1177 + clear_output_shown() -> 1178 + erase(gleshell_output_shown), 1179 + nil. 1180 + 1181 + %% Find a terminal device attached to this BEAM (or an ancestor). 1182 + %% Note: os:getpid() returns a string, not an integer. 1183 + find_tty_path() -> 1184 + case catch list_to_integer(os:getpid()) of 1185 + Pid when is_integer(Pid) -> 1186 + case tty_path_for_pid(Pid) of 1187 + {ok, _} = Ok -> 1188 + Ok; 1189 + error -> 1190 + walk_parent_tty(Pid, 12) 1191 + end; 1192 + _ -> 1193 + error 1194 + end. 1195 + 1196 + walk_parent_tty(_Pid, 0) -> 1197 + error; 1198 + walk_parent_tty(Pid, N) when is_integer(Pid), Pid > 1 -> 1199 + case parent_pid(Pid) of 1200 + {ok, Parent} when Parent > 1, Parent =/= Pid -> 1201 + case tty_path_for_pid(Parent) of 1202 + {ok, _} = Ok -> 1203 + Ok; 1204 + error -> 1205 + walk_parent_tty(Parent, N - 1) 1206 + end; 1207 + _ -> 1208 + error 1209 + end; 1210 + walk_parent_tty(_, _) -> 1211 + error. 1212 + 1213 + tty_path_for_pid(Pid) when is_integer(Pid) -> 1214 + case read_tty_nr(Pid) of 1215 + {ok, 0} -> 1216 + error; 1217 + {ok, TtyNr} -> 1218 + tty_nr_to_path(TtyNr); 1219 + error -> 1220 + error 1221 + end. 1222 + 1223 + read_tty_nr(Pid) when is_integer(Pid) -> 1224 + case file:read_file("/proc/" ++ integer_to_list(Pid) ++ "/stat") of 1225 + {ok, Bin} -> 1226 + case parse_stat_tty_nr(binary_to_list(Bin)) of 1227 + {ok, N} -> {ok, N}; 1228 + error -> error 1229 + end; 1230 + _ -> 1231 + error 1232 + end. 1233 + %% /proc/pid/stat: "pid (comm) state ppid pgrp session tty_nr ..." 1234 + parse_stat_tty_nr(List) -> 1235 + case lists:splitwith(fun(C) -> C =/= $) end, List) of 1236 + {_, [$) | Rest0]} -> 1237 + Rest = string:trim(Rest0, leading), 1238 + Fields = string:tokens(Rest, " "), 1239 + %% After ')': state, ppid, pgrp, session, tty_nr → index 5 1240 + case length(Fields) >= 5 of 1241 + true -> 1242 + try 1243 + {ok, list_to_integer(lists:nth(5, Fields))} 1244 + catch 1245 + _:_ -> error 1246 + end; 1247 + false -> 1248 + error 1249 + end; 1250 + _ -> 1251 + error 1252 + end. 1253 + 1254 + parent_pid(Pid) -> 1255 + case file:read_file("/proc/" ++ integer_to_list(Pid) ++ "/stat") of 1256 + {ok, Bin} -> 1257 + case parse_stat_ppid(binary_to_list(Bin)) of 1258 + {ok, P} -> {ok, P}; 1259 + error -> error 1260 + end; 1261 + _ -> 1262 + error 1263 + end. 1264 + 1265 + parse_stat_ppid(List) -> 1266 + case lists:splitwith(fun(C) -> C =/= $) end, List) of 1267 + {_, [$) | Rest0]} -> 1268 + Rest = string:trim(Rest0, leading), 1269 + Fields = string:tokens(Rest, " "), 1270 + %% After ')': state, ppid → index 2 1271 + case length(Fields) >= 2 of 1272 + true -> 1273 + try 1274 + {ok, list_to_integer(lists:nth(2, Fields))} 1275 + catch 1276 + _:_ -> error 1277 + end; 1278 + false -> 1279 + error 1280 + end; 1281 + _ -> 1282 + error 1283 + end. 1284 + 1285 + %% Decode Linux tty_nr (see drivers/tty/tty_io.c / procfs) to a device path. 1286 + tty_nr_to_path(0) -> 1287 + error; 1288 + tty_nr_to_path(TtyNr) when is_integer(TtyNr) -> 1289 + Major = (TtyNr bsr 8) band 16#ff, 1290 + Minor = (TtyNr band 16#ff) bor (((TtyNr bsr 20) band 16#fff) bsl 8), 1291 + Path = 1292 + case Major of 1293 + 136 -> 1294 + "/dev/pts/" ++ integer_to_list(Minor); 1295 + 4 when Minor >= 64 -> 1296 + "/dev/ttyS" ++ integer_to_list(Minor - 64); 1297 + 4 -> 1298 + "/dev/tty" ++ integer_to_list(Minor); 1299 + _ -> 1300 + undefined 1301 + end, 1302 + case Path of 1303 + undefined -> 1304 + error; 1305 + _ -> 1306 + case file:read_file_info(Path) of 1307 + {ok, _} -> {ok, Path}; 1308 + _ -> error 1309 + end 254 1310 end. 255 1311 256 1312 reason_to_bin(Reason) when is_atom(Reason) ->
+112
test/gleshell_test.gleam
··· 4 4 import gleshell/display 5 5 import gleshell/env 6 6 import gleshell/eval 7 + import gleshell/highlight 7 8 import gleshell/lexer 8 9 import gleshell/parser 10 + import gleshell/sys 9 11 import gleshell/value.{Bool, Int, List, Nothing, Record, String, Table} 10 12 11 13 pub fn main() -> Nil { ··· 118 120 Nil 119 121 } 120 122 123 + pub fn eval_env_var_get_test() { 124 + // `$env.HOME` and bare `$env.HOME` as pipeline source 125 + let env = env.new() 126 + let assert Ok(home) = sys.getenv("HOME") 127 + let assert eval.Continue(_, String(got)) = 128 + eval.eval_source(env, "echo $env.HOME") 129 + let assert True = got == home 130 + let assert eval.Continue(_, String(got2)) = eval.eval_source(env, "$env.HOME") 131 + let assert True = got2 == home 132 + Nil 133 + } 134 + 135 + pub fn eval_env_record_test() { 136 + let env = env.new() 137 + let assert eval.Continue(_, Record(fields)) = eval.eval_source(env, "$env") 138 + let assert True = list_has_string_field(fields, "HOME") 139 + let assert True = list_has_string_field(fields, "PATH") 140 + // `$env | get HOME` 141 + let assert eval.Continue(_, String(home)) = 142 + eval.eval_source(env, "$env | get HOME") 143 + let assert True = string.length(home) > 0 144 + Nil 145 + } 146 + 147 + pub fn eval_env_assign_test() { 148 + let env = env.new() 149 + let assert eval.Continue(env2, String("gleshell-test-val")) = 150 + eval.eval_source(env, "$env.GLESHELL_TEST_VAR = gleshell-test-val") 151 + let assert eval.Continue(_, String("gleshell-test-val")) = 152 + eval.eval_source(env2, "$env.GLESHELL_TEST_VAR") 153 + let assert Ok("gleshell-test-val") = sys.getenv("GLESHELL_TEST_VAR") 154 + Nil 155 + } 156 + 157 + pub fn parse_env_assign_test() { 158 + let assert Ok(parser.EnvAssign("FOO", parser.Pipeline([cmd]))) = 159 + parser.parse("$env.FOO = hello") 160 + let assert parser.Command( 161 + "__value__", 162 + [parser.ValueArg(parser.Lit(String("hello")))], 163 + False, 164 + ) = cmd 165 + Nil 166 + } 167 + 168 + fn list_has_string_field( 169 + fields: List(#(String, value.Value)), 170 + key: String, 171 + ) -> Bool { 172 + case fields { 173 + [] -> False 174 + [#(k, String(_)), ..] if k == key -> True 175 + [_, ..rest] -> list_has_string_field(rest, key) 176 + } 177 + } 178 + 121 179 pub fn eval_where_select_test() { 122 180 let env = env.new() 123 181 // build table via records in a list, convert with table ··· 223 281 let painted = color.paint(True, "\u{001b}[32m", "hi") 224 282 let assert 2 = color.visible_length(painted) 225 283 let assert 2 = color.visible_length("hi") 284 + Nil 285 + } 286 + 287 + // --- input syntax highlighting --- 288 + 289 + pub fn highlight_plain_when_off_test() { 290 + let src = "ls | where type == file" 291 + let assert True = highlight.highlight(False, src) == src 292 + Nil 293 + } 294 + 295 + pub fn highlight_pipeline_has_shapes_test() { 296 + let text = highlight.highlight(True, "ls | first 3") 297 + // bold cyan internalcall, bold purple pipe, bold purple int 298 + let assert True = string_contains(text, "\u{001b}[1;36m") 299 + let assert True = string_contains(text, "\u{001b}[1;35m") 300 + let assert True = string_contains(text, "ls") 301 + let assert True = string_contains(text, "first") 302 + let assert True = string_contains(text, "3") 303 + // visible text unchanged 304 + let assert 12 = color.visible_length(text) 305 + Nil 306 + } 307 + 308 + pub fn highlight_string_and_flag_test() { 309 + let text = highlight.highlight(True, "echo \"hi\" --raw") 310 + let assert True = string_contains(text, "\u{001b}[32m") 311 + let assert True = string_contains(text, "\u{001b}[1;34m") 312 + let assert True = string_contains(text, "hi") 313 + let assert True = string_contains(text, "--raw") 314 + Nil 315 + } 316 + 317 + pub fn highlight_variable_and_let_test() { 318 + let text = highlight.highlight(True, "let x = $in") 319 + let assert True = string_contains(text, "\u{001b}[1;36m") 320 + let assert True = string_contains(text, "\u{001b}[35m") 321 + let assert True = string_contains(text, "let") 322 + let assert True = string_contains(text, "$in") 323 + Nil 324 + } 325 + 326 + pub fn highlight_incomplete_string_test() { 327 + // Unterminated string while typing should not crash 328 + let text = highlight.highlight(True, "echo \"hel") 329 + let assert True = string_contains(text, "hel") 330 + let assert True = string_contains(text, "\u{001b}[32m") 331 + Nil 332 + } 333 + 334 + pub fn highlight_to_json_multiword_test() { 335 + let text = highlight.highlight(True, "range 3 | to json") 336 + let assert True = string_contains(text, "to json") 337 + let assert True = string_contains(text, "\u{001b}[1;36m") 226 338 Nil 227 339 } 228 340