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 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