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