A structured-data shell in Gleam, inspired by Nushell
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, grey history hints, Ctrl+R fuzzy history)",
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