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/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 (vim, sudo, …). Skip re-printing when that flag is still set.
270 // Everything else (including systemctl/git/man with nested pagers forced
271 // to cat) is captured and shown here — through the builtin pager when
272 // allowed and the text does 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