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

Configure Feed

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

gleshell / src / gleshell.gleam
7.7 kB 280 lines
1//// gleshell — a structured-data shell in Gleam, inspired by Nushell. 2 3import argv 4import filepath 5import gleam/io 6import gleam/string 7import gleshell/color 8import gleshell/display 9import gleshell/env 10import gleshell/eval 11import gleshell/sys 12import gleshell/value.{Nothing} 13import simplifile 14 15pub fn main() -> Nil { 16 case argv.load().arguments { 17 ["-h"] | ["--help"] | ["help"] -> { 18 print_usage() 19 Nil 20 } 21 ["-c", code] -> run_once(code) 22 ["-c"] -> { 23 io.println_error("gleshell: -c requires a command string") 24 halt(2) 25 } 26 [] -> sys.run_as_shell(fn() { repl(env.new()) }) 27 args -> run_once(string.join(args, " ")) 28 } 29} 30 31fn print_usage() -> Nil { 32 sys.println(string.join( 33 [ 34 "gleshell — Gleam shell inspired by Nushell", 35 "", 36 "Usage:", 37 " gleshell Interactive REPL", 38 " gleshell -c <code> Evaluate a one-liner", 39 " gleshell <code>… Evaluate remaining args as code", 40 "", 41 "Examples:", 42 " gleshell -c 'ls | where type == file | first 5'", 43 " gleshell -c 'range 10 | reverse | first 3'", 44 " gleshell -c 'echo {name: \"gleshell\", cool: true}'", 45 "", 46 "In the REPL, type `help` for built-in commands.", 47 ], 48 "\n", 49 )) 50} 51 52fn run_once(code: String) -> Nil { 53 let env = env.new() 54 case eval.eval_source(env, code) { 55 eval.Quit(code) -> halt(code) 56 eval.Continue(_env, value) -> { 57 print_value(value) 58 case value { 59 value.Fail(_) -> halt(1) 60 _ -> Nil 61 } 62 } 63 } 64} 65 66fn repl(env: env.Env) -> Nil { 67 sys.println( 68 "gleshell 0.1 — structured data shell (type `help`, `exit` to quit; Tab completes commands/paths, Ctrl+R search)", 69 ) 70 repl_loop(env) 71} 72 73fn repl_loop(env: env.Env) -> Nil { 74 let prompt = prompt_for(env) 75 case sys.get_line(prompt) { 76 Error("eof") -> { 77 sys.println("") 78 Nil 79 } 80 // Ctrl+C cancelled the line (edlin path); stay in the REPL. 81 Error("interrupted") -> { 82 sys.println("") 83 repl_loop(env) 84 } 85 Error(e) -> { 86 io.println_error("read error: " <> e) 87 Nil 88 } 89 Ok(line) -> { 90 let line = string.trim(line) 91 case line { 92 "" -> repl_loop(env) 93 _ -> 94 case eval.eval_source(env, line) { 95 eval.Quit(code) -> { 96 case code { 97 0 -> Nil 98 _ -> halt(code) 99 } 100 } 101 eval.Continue(env2, value) -> { 102 print_value(value) 103 repl_loop(env2) 104 } 105 } 106 } 107 } 108 } 109} 110 111/// Zero-config prompt inspired by Starship: 112/// blank line, directory (+ optional git branch), then green/red Nerd Font 113/// shell icon as the prompt character. 114/// 115/// Status lines are printed once; only the character is the line-editor prompt 116/// (the raw editor redraws a single line). 117/// 118/// Icon `` is nf-oct-terminal (Nerd Fonts). Install `nerd-fonts.symbols-only` 119/// (or any Nerd Font) so it renders — see the flake packages / devenv. 120fn prompt_for(env: env.Env) -> String { 121 let on = color.enabled() 122 let path = color.prompt_path(on, display_cwd(env.cwd)) 123 let git = case git_branch(env.cwd) { 124 Ok(branch) -> 125 color.separator(on, " on ") 126 <> color.prompt_git(on, "" <> branch) 127 Error(Nil) -> "" 128 } 129 sys.println("") 130 sys.println(path <> git) 131 prompt_character(env.last_exit) 132} 133 134fn prompt_character(last_exit: Int) -> String { 135 let on = color.enabled() 136 // Nerd Font terminal icon; green after success, red after non-zero exit. 137 // Spaces go *after* the color reset. PUA glyphs (nf-oct-terminal) are often 138 // drawn ~2 cells wide while the terminal advances only 1, so a single space 139 // sits under the icon ink and looks missing — use two. 140 let icon = case last_exit { 141 0 -> color.prompt_character_ok(on, "") 142 _ -> color.prompt_character_err(on, "") 143 } 144 icon <> " " 145} 146 147/// Full cwd for the prompt, with `$HOME` shown as `~`. 148fn display_cwd(cwd: String) -> String { 149 case sys.home_dir() { 150 Ok(home) -> abbreviate_home(cwd, home) 151 Error(_) -> cwd 152 } 153} 154 155fn abbreviate_home(path: String, home: String) -> String { 156 case path == home { 157 True -> "~" 158 False -> 159 case string.starts_with(path, home <> "/") { 160 // Keep the leading `/` after home so `~/code` not `~code`. 161 True -> "~" <> string.drop_start(path, string.length(home)) 162 False -> path 163 } 164 } 165} 166 167/// Best-effort branch name by reading `.git` (no `git` process). 168fn git_branch(cwd: String) -> Result(String, Nil) { 169 case find_git_dir(cwd, 32) { 170 Error(Nil) -> Error(Nil) 171 Ok(git_dir) -> read_git_head_branch(git_dir) 172 } 173} 174 175fn find_git_dir(dir: String, budget: Int) -> Result(String, Nil) { 176 case budget <= 0 { 177 True -> Error(Nil) 178 False -> { 179 let candidate = filepath.join(dir, ".git") 180 case simplifile.is_directory(candidate) { 181 Ok(True) -> Ok(candidate) 182 _ -> 183 case simplifile.is_file(candidate) { 184 Ok(True) -> read_gitdir_pointer(candidate) 185 _ -> { 186 let parent = filepath.directory_name(dir) 187 case parent == "" || parent == dir { 188 True -> Error(Nil) 189 False -> find_git_dir(parent, budget - 1) 190 } 191 } 192 } 193 } 194 } 195 } 196} 197 198/// Worktree / linked checkout: `.git` is a file `gitdir: <path>`. 199fn read_gitdir_pointer(git_file: String) -> Result(String, Nil) { 200 case simplifile.read(git_file) { 201 Error(_) -> Error(Nil) 202 Ok(body) -> { 203 let line = string.trim(first_line(body)) 204 case string.starts_with(line, "gitdir:") { 205 False -> Error(Nil) 206 True -> { 207 let raw = string.trim(string.drop_start(line, 7)) 208 case raw { 209 "" -> Error(Nil) 210 path -> 211 case filepath.is_absolute(path) { 212 True -> Ok(path) 213 False -> 214 Ok(filepath.join(filepath.directory_name(git_file), path)) 215 } 216 } 217 } 218 } 219 } 220 } 221} 222 223fn read_git_head_branch(git_dir: String) -> Result(String, Nil) { 224 case simplifile.read(filepath.join(git_dir, "HEAD")) { 225 Error(_) -> Error(Nil) 226 Ok(body) -> { 227 let line = string.trim(first_line(body)) 228 case string.starts_with(line, "ref: ") { 229 True -> { 230 let ref = string.trim(string.drop_start(line, 5)) 231 // Prefer short branch name: refs/heads/main → main 232 case string.starts_with(ref, "refs/heads/") { 233 True -> Ok(string.drop_start(ref, 11)) 234 False -> Ok(filepath.base_name(ref)) 235 } 236 } 237 // Detached HEAD — show a short SHA when it looks like one. 238 False -> 239 case string.length(line) >= 7 { 240 True -> Ok(string.slice(line, 0, 7)) 241 False -> 242 case line { 243 "" -> Error(Nil) 244 other -> Ok(other) 245 } 246 } 247 } 248 } 249 } 250} 251 252fn first_line(s: String) -> String { 253 case string.split_once(s, "\n") { 254 Ok(#(line, _)) -> line 255 Error(Nil) -> s 256 } 257} 258 259fn print_value(value: value.Value) -> Nil { 260 case value { 261 Nothing -> Nil 262 _ -> 263 // External commands that used PTY relay already streamed output to the 264 // terminal (needed for run0/sudo password prompts). Skip re-printing 265 // when that flag is still set (final pipeline stage was that external). 266 case sys.take_output_shown() { 267 True -> Nil 268 False -> { 269 let text = display.render(value) 270 case text { 271 "" -> Nil 272 t -> sys.println(t) 273 } 274 } 275 } 276 } 277} 278 279@external(erlang, "erlang", "halt") 280fn halt(status: Int) -> Nil