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.2 kB 272 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 a green/red `❯`. 113/// 114/// Status lines are printed once; only the character is the line-editor prompt 115/// (the raw editor redraws a single line). 116fn prompt_for(env: env.Env) -> String { 117 let on = color.enabled() 118 let path = color.prompt_path(on, display_cwd(env.cwd)) 119 let git = case git_branch(env.cwd) { 120 Ok(branch) -> 121 color.separator(on, " on ") 122 <> color.prompt_git(on, "" <> branch) 123 Error(Nil) -> "" 124 } 125 sys.println("") 126 sys.println(path <> git) 127 prompt_character(env.last_exit) 128} 129 130fn prompt_character(last_exit: Int) -> String { 131 let on = color.enabled() 132 let mark = case last_exit { 133 0 -> color.prompt_character_ok(on, "") 134 _ -> color.prompt_character_err(on, "") 135 } 136 mark <> " " 137} 138 139/// Full cwd for the prompt, with `$HOME` shown as `~`. 140fn display_cwd(cwd: String) -> String { 141 case sys.home_dir() { 142 Ok(home) -> abbreviate_home(cwd, home) 143 Error(_) -> cwd 144 } 145} 146 147fn abbreviate_home(path: String, home: String) -> String { 148 case path == home { 149 True -> "~" 150 False -> 151 case string.starts_with(path, home <> "/") { 152 // Keep the leading `/` after home so `~/code` not `~code`. 153 True -> "~" <> string.drop_start(path, string.length(home)) 154 False -> path 155 } 156 } 157} 158 159/// Best-effort branch name by reading `.git` (no `git` process). 160fn git_branch(cwd: String) -> Result(String, Nil) { 161 case find_git_dir(cwd, 32) { 162 Error(Nil) -> Error(Nil) 163 Ok(git_dir) -> read_git_head_branch(git_dir) 164 } 165} 166 167fn find_git_dir(dir: String, budget: Int) -> Result(String, Nil) { 168 case budget <= 0 { 169 True -> Error(Nil) 170 False -> { 171 let candidate = filepath.join(dir, ".git") 172 case simplifile.is_directory(candidate) { 173 Ok(True) -> Ok(candidate) 174 _ -> 175 case simplifile.is_file(candidate) { 176 Ok(True) -> read_gitdir_pointer(candidate) 177 _ -> { 178 let parent = filepath.directory_name(dir) 179 case parent == "" || parent == dir { 180 True -> Error(Nil) 181 False -> find_git_dir(parent, budget - 1) 182 } 183 } 184 } 185 } 186 } 187 } 188} 189 190/// Worktree / linked checkout: `.git` is a file `gitdir: <path>`. 191fn read_gitdir_pointer(git_file: String) -> Result(String, Nil) { 192 case simplifile.read(git_file) { 193 Error(_) -> Error(Nil) 194 Ok(body) -> { 195 let line = string.trim(first_line(body)) 196 case string.starts_with(line, "gitdir:") { 197 False -> Error(Nil) 198 True -> { 199 let raw = string.trim(string.drop_start(line, 7)) 200 case raw { 201 "" -> Error(Nil) 202 path -> 203 case filepath.is_absolute(path) { 204 True -> Ok(path) 205 False -> 206 Ok(filepath.join(filepath.directory_name(git_file), path)) 207 } 208 } 209 } 210 } 211 } 212 } 213} 214 215fn read_git_head_branch(git_dir: String) -> Result(String, Nil) { 216 case simplifile.read(filepath.join(git_dir, "HEAD")) { 217 Error(_) -> Error(Nil) 218 Ok(body) -> { 219 let line = string.trim(first_line(body)) 220 case string.starts_with(line, "ref: ") { 221 True -> { 222 let ref = string.trim(string.drop_start(line, 5)) 223 // Prefer short branch name: refs/heads/main → main 224 case string.starts_with(ref, "refs/heads/") { 225 True -> Ok(string.drop_start(ref, 11)) 226 False -> Ok(filepath.base_name(ref)) 227 } 228 } 229 // Detached HEAD — show a short SHA when it looks like one. 230 False -> 231 case string.length(line) >= 7 { 232 True -> Ok(string.slice(line, 0, 7)) 233 False -> 234 case line { 235 "" -> Error(Nil) 236 other -> Ok(other) 237 } 238 } 239 } 240 } 241 } 242} 243 244fn first_line(s: String) -> String { 245 case string.split_once(s, "\n") { 246 Ok(#(line, _)) -> line 247 Error(Nil) -> s 248 } 249} 250 251fn print_value(value: value.Value) -> Nil { 252 case value { 253 Nothing -> Nil 254 _ -> 255 // External commands that used PTY relay already streamed output to the 256 // terminal (needed for run0/sudo password prompts). Skip re-printing 257 // when that flag is still set (final pipeline stage was that external). 258 case sys.take_output_shown() { 259 True -> Nil 260 False -> { 261 let text = display.render(value) 262 case text { 263 "" -> Nil 264 t -> sys.println(t) 265 } 266 } 267 } 268 } 269} 270 271@external(erlang, "erlang", "halt") 272fn halt(status: Int) -> Nil