A structured-data shell in Gleam, inspired by Nushell
1import gleam/list
2import gleam/string
3import gleeunit
4import gleshell/builtins
5import gleshell/color
6import gleshell/display
7import gleshell/env
8import gleshell/eval
9import gleshell/highlight
10import gleshell/lexer
11import gleshell/parser
12import gleshell/sys
13import gleshell/value.{Bool, Int, List, Nothing, Record, String, Table}
14
15pub fn main() -> Nil {
16 gleeunit.main()
17}
18
19// --- lexer ---
20
21pub fn lexer_pipeline_test() {
22 let assert Ok(tokens) = lexer.tokenize("ls | where type == file")
23 let assert [lexer.Ident("ls"), lexer.Pipe, lexer.Ident("where"), ..] = tokens
24 Nil
25}
26
27pub fn lexer_string_and_number_test() {
28 let assert Ok(tokens) = lexer.tokenize("echo \"hi\" 42 3.14 true")
29 let assert [
30 lexer.Ident("echo"),
31 lexer.StringLit("hi"),
32 lexer.IntLit(42),
33 lexer.FloatLit(3.14),
34 lexer.BoolLit(True),
35 lexer.Eof,
36 ] = tokens
37 Nil
38}
39
40pub fn lexer_path_idents_test() {
41 // Dotfiles, relative/absolute paths, home — must be bare words for `ls .jj` etc.
42 let assert Ok(tokens) = lexer.tokenize("ls .jj ./src ../foo /tmp ~ ~/code")
43 let assert [
44 lexer.Ident("ls"),
45 lexer.Ident(".jj"),
46 lexer.Ident("./src"),
47 lexer.Ident("../foo"),
48 lexer.Ident("/tmp"),
49 lexer.Ident("~"),
50 lexer.Ident("~/code"),
51 lexer.Eof,
52 ] = tokens
53 Nil
54}
55
56pub fn parse_ls_dotfile_test() {
57 let assert Ok(parser.Expr(parser.Pipeline([
58 parser.Command("ls", args, False),
59 ]))) = parser.parse("ls .jj")
60 let assert [parser.ValueArg(parser.Lit(String(".jj")))] = args
61 Nil
62}
63
64// --- parser ---
65
66pub fn parse_pipeline_test() {
67 let assert Ok(parser.Expr(parser.Pipeline(cmds))) =
68 parser.parse("ls | first 3")
69 let assert [
70 parser.Command("ls", [], False),
71 parser.Command("first", args, False),
72 ] = cmds
73 let assert [parser.ValueArg(parser.Lit(Int(3)))] = args
74 Nil
75}
76
77pub fn parse_let_test() {
78 let assert Ok(parser.Let(
79 "x",
80 parser.Pipeline([parser.Command("echo", args, False)]),
81 )) = parser.parse("let x = echo 1")
82 let assert [parser.ValueArg(parser.Lit(Int(1)))] = args
83 Nil
84}
85
86pub fn parse_where_ops_test() {
87 let assert Ok(parser.Expr(parser.Pipeline([
88 parser.Command("where", args, False),
89 ]))) = parser.parse("where type == file")
90 let assert [
91 parser.ValueArg(parser.Lit(String("type"))),
92 parser.ValueArg(parser.Lit(String("=="))),
93 parser.ValueArg(parser.Lit(String("file"))),
94 ] = args
95 Nil
96}
97
98pub fn parse_list_and_record_test() {
99 let assert Ok(parser.Expr(parser.Pipeline([
100 parser.Command("echo", args, False),
101 ]))) = parser.parse("echo [1 2] {a: true}")
102 let assert [
103 parser.ValueArg(parser.ListExpr([parser.Lit(Int(1)), parser.Lit(Int(2))])),
104 parser.ValueArg(parser.RecordExpr([#("a", parser.Lit(Bool(True)))])),
105 ] = args
106 Nil
107}
108
109// --- value helpers ---
110
111pub fn table_from_records_test() {
112 let rows = [
113 Record([#("name", String("a")), #("n", Int(1))]),
114 Record([#("name", String("b")), #("n", Int(2))]),
115 ]
116 let assert Table(
117 ["name", "n"],
118 [[String("a"), Int(1)], [String("b"), Int(2)]],
119 ) = value.table_from_records(rows)
120 Nil
121}
122
123// --- eval ---
124
125pub fn eval_echo_and_range_test() {
126 let env = env.new()
127 let assert eval.Continue(_, List([Int(0), Int(1), Int(2)])) =
128 eval.eval_source(env, "range 3")
129 let assert eval.Continue(_, String("hello")) =
130 eval.eval_source(env, "echo hello")
131 Nil
132}
133
134pub fn eval_pipeline_reverse_first_test() {
135 let env = env.new()
136 let assert eval.Continue(_, Int(2)) =
137 eval.eval_source(env, "range 3 | reverse | first")
138 Nil
139}
140
141/// Pipeline input must become the external's stdin (`cat f | less`, `echo hi | wc`).
142/// Use `let` so the last stage is capture mode even on a TTY (bare expressions
143/// may inherit the terminal and return an empty string value).
144pub fn eval_pipeline_stdin_to_external_test() {
145 let env = env.new()
146 // `echo` is a builtin; `wc` is external. Count bytes of "hello" (no trailing NL).
147 let assert eval.Continue(_, String(out)) =
148 eval.eval_source(env, "let n = echo hello | ^wc -c")
149 let assert True = string.contains(out, "5")
150 // Tight `cmd|cmd` form (no spaces around pipe)
151 let assert eval.Continue(_, String(out2)) =
152 eval.eval_source(env, "let m = echo ab|^wc -c")
153 let assert True = string.contains(out2, "2")
154 Nil
155}
156
157pub fn eval_let_and_var_test() {
158 let env = env.new()
159 let assert eval.Continue(env2, Int(7)) =
160 eval.eval_source(env, "let n = echo 7")
161 let assert eval.Continue(_, Int(7)) = eval.eval_source(env2, "echo $n")
162 Nil
163}
164
165pub fn eval_env_var_get_test() {
166 // `$env.HOME` and bare `$env.HOME` as pipeline source
167 let env = env.new()
168 let assert Ok(home) = sys.getenv("HOME")
169 let assert eval.Continue(_, String(got)) =
170 eval.eval_source(env, "echo $env.HOME")
171 let assert True = got == home
172 let assert eval.Continue(_, String(got2)) = eval.eval_source(env, "$env.HOME")
173 let assert True = got2 == home
174 Nil
175}
176
177pub fn eval_env_record_test() {
178 let env = env.new()
179 let assert eval.Continue(_, Record(fields)) = eval.eval_source(env, "$env")
180 let assert True = list_has_string_field(fields, "HOME")
181 let assert True = list_has_string_field(fields, "PATH")
182 // `$env | get HOME`
183 let assert eval.Continue(_, String(home)) =
184 eval.eval_source(env, "$env | get HOME")
185 let assert True = string.length(home) > 0
186 Nil
187}
188
189pub fn eval_env_assign_test() {
190 let env = env.new()
191 let assert eval.Continue(env2, String("gleshell-test-val")) =
192 eval.eval_source(env, "$env.GLESHELL_TEST_VAR = gleshell-test-val")
193 let assert eval.Continue(_, String("gleshell-test-val")) =
194 eval.eval_source(env2, "$env.GLESHELL_TEST_VAR")
195 let assert Ok("gleshell-test-val") = sys.getenv("GLESHELL_TEST_VAR")
196 Nil
197}
198
199pub fn parse_env_assign_test() {
200 let assert Ok(parser.EnvAssign("FOO", parser.Pipeline([cmd]))) =
201 parser.parse("$env.FOO = hello")
202 let assert parser.Command(
203 "__value__",
204 [parser.ValueArg(parser.Lit(String("hello")))],
205 False,
206 ) = cmd
207 Nil
208}
209
210fn list_has_string_field(
211 fields: List(#(String, value.Value)),
212 key: String,
213) -> Bool {
214 case fields {
215 [] -> False
216 [#(k, String(_)), ..] if k == key -> True
217 [_, ..rest] -> list_has_string_field(rest, key)
218 }
219}
220
221pub fn eval_where_select_test() {
222 let env = env.new()
223 // build table via records in a list, convert with table
224 let assert eval.Continue(_, result) =
225 eval.eval_source(
226 env,
227 "echo [{name: a, n: 1} {name: b, n: 2} {name: c, n: 3}] | table | where n > 1 | select name",
228 )
229 let assert Table(["name"], rows) = result
230 let assert [[String("b")], [String("c")]] = rows
231 Nil
232}
233
234pub fn help_covers_all_builtins_test() {
235 // Every registered builtin must have a dedicated help_text entry.
236 let assert [] = builtins.missing_help()
237
238 let env = env.new()
239 // which finds builtins; help must too (not "unknown command")
240 let assert eval.Continue(_, String(which_out)) =
241 eval.eval_source(env, "which table")
242 let assert "builtin: table" = which_out
243 let assert eval.Continue(_, String(help_out)) =
244 eval.eval_source(env, "help table")
245 let assert True = string.contains(help_out, "table")
246 let assert True = string.contains(help_out, "coerce")
247
248 // Parent commands with subcommands: `help to` / `help from` must resolve.
249 let assert eval.Continue(_, String(to_help)) =
250 eval.eval_source(env, "help to")
251 let assert True = string.contains(to_help, "json")
252 let assert eval.Continue(_, String(from_help)) =
253 eval.eval_source(env, "help from")
254 let assert True = string.contains(from_help, "json")
255
256 // Bare help lists every command with its one-line description.
257 let assert eval.Continue(_, String(all)) = eval.eval_source(env, "help")
258 list.each(builtins.names(), fn(name) {
259 let assert True = string.contains(all, name)
260 })
261
262 let assert eval.Continue(env2, value.Fail(msg)) =
263 eval.eval_source(env, "help not-a-real-cmd")
264 let assert True = string.contains(msg, "unknown command")
265 let assert 1 = env2.last_exit
266 Nil
267}
268
269pub fn eval_from_json_test() {
270 let env = env.new()
271 let assert eval.Continue(_, Record(fields)) =
272 eval.eval_source(env, "echo \"{\\\"x\\\": 1}\" | from json")
273 let assert True = list_has_field(fields, "x", Int(1))
274 Nil
275}
276
277pub fn eval_to_json_pretty_test() {
278 let env = env.new()
279 // `to` command + `json` subcommand — pretty by default
280 let assert eval.Continue(_, String(pretty)) =
281 eval.eval_source(env, "echo [1 2 3] | to json")
282 let assert True = string.contains(pretty, "\n")
283 let assert True = string.contains(pretty, "1")
284 // `--raw` matches Nu: compact, no trailing newline
285 let assert eval.Continue(_, String(raw)) =
286 eval.eval_source(env, "echo [1 2 3] | to json --raw")
287 let assert "[1,2,3]" = raw
288 // Missing / unknown subcommand
289 let assert eval.Continue(env2, value.Fail(msg)) =
290 eval.eval_source(env, "echo 1 | to")
291 let assert True = string.contains(msg, "subcommand")
292 let assert 1 = env2.last_exit
293 let assert eval.Continue(_, value.Fail(msg2)) =
294 eval.eval_source(env, "echo 1 | to yaml")
295 let assert True = string.contains(msg2, "unknown subcommand")
296 Nil
297}
298
299pub fn eval_to_json_record_test() {
300 let env = env.new()
301 let assert eval.Continue(_, String(raw)) =
302 eval.eval_source(env, "echo {a: 1, b: true} | to json -r")
303 let assert True = string.contains(raw, "\"a\":1")
304 let assert True = string.contains(raw, "\"b\":true")
305 Nil
306}
307
308fn list_has_field(
309 fields: List(#(String, value.Value)),
310 key: String,
311 expected: value.Value,
312) -> Bool {
313 case fields {
314 [] -> False
315 [#(k, v), ..rest] ->
316 case k == key {
317 True -> value.equals(v, expected)
318 False -> list_has_field(rest, key, expected)
319 }
320 }
321}
322
323pub fn eval_length_test() {
324 let env = env.new()
325 let assert eval.Continue(_, Int(4)) =
326 eval.eval_source(env, "range 4 | length")
327 Nil
328}
329
330pub fn eval_which_builtin_test() {
331 let env = env.new()
332 let assert eval.Continue(_, String("builtin: ls")) =
333 eval.eval_source(env, "which ls")
334 Nil
335}
336
337pub fn eval_find_list_test() {
338 let env = env.new()
339 // Substring match (OR across terms)
340 let assert eval.Continue(_, List(items)) =
341 eval.eval_source(env, "echo [moe larry curly] | find l")
342 let assert [String("larry"), String("curly")] = items
343 // Multiple terms
344 let assert eval.Continue(_, List(items2)) =
345 eval.eval_source(env, "echo [a.toml b.md c.rs] | find toml md")
346 let assert [String("a.toml"), String("b.md")] = items2
347 // Exact number match
348 let assert eval.Continue(_, List([Int(5)])) =
349 eval.eval_source(env, "echo [1 5 3 4 35] | find 5")
350 Nil
351}
352
353pub fn eval_find_ignore_case_invert_test() {
354 let env = env.new()
355 let assert eval.Continue(_, List(items)) =
356 eval.eval_source(env, "echo [Hello world HELLO] | find hello -i")
357 let assert [String("Hello"), String("HELLO")] = items
358 // `-i term` may attach term to the flag; still works
359 let assert eval.Continue(_, List(items2)) =
360 eval.eval_source(env, "echo [Hello world HELLO] | find -i hello")
361 let assert [String("Hello"), String("HELLO")] = items2
362 let assert eval.Continue(_, List([String("cd")])) =
363 eval.eval_source(env, "echo [ab cd] | find --invert a")
364 Nil
365}
366
367pub fn eval_find_table_and_string_test() {
368 let env = env.new()
369 let assert eval.Continue(_, Table(["name", "type"], rows)) =
370 eval.eval_source(
371 env,
372 "echo [{name: a, type: file} {name: b, type: dir}] | table | find file",
373 )
374 let assert [[String("a"), String("file")]] = rows
375 // Single-line string: return the string if match, else nothing
376 let assert eval.Continue(_, String("Cargo.toml")) =
377 eval.eval_source(env, "echo Cargo.toml | find Cargo")
378 let assert eval.Continue(_, Nothing) =
379 eval.eval_source(env, "echo Cargo.toml | find zz")
380 // Multi-line string → list of matching lines
381 let assert eval.Continue(_, List(lines)) =
382 eval.eval_source(env, "echo \"hi\nbye\nhi there\" | find hi")
383 let assert [String("hi"), String("hi there")] = lines
384 Nil
385}
386
387pub fn eval_find_regex_test() {
388 let env = env.new()
389 let assert eval.Continue(_, List(items)) =
390 eval.eval_source(env, "echo [abc odb arc abf] | find --regex \"b.\"")
391 let assert [String("abc"), String("abf")] = items
392 // Regex + terms is rejected (Nu-compatible)
393 let assert eval.Continue(env2, value.Fail(msg)) =
394 eval.eval_source(env, "echo [abc] | find --regex \"b.\" x")
395 let assert True = string.contains(msg, "regex")
396 let assert 1 = env2.last_exit
397 Nil
398}
399
400pub fn eval_which_all_test() {
401 let env = env.new()
402 // `-a` includes the builtin first, then any PATH copies of `ls`.
403 let assert eval.Continue(_, result) = eval.eval_source(env, "which -a ls")
404 case result {
405 String("builtin: ls") -> Nil
406 List([String("builtin: ls"), ..]) -> Nil
407 other -> {
408 let _ = other
409 panic as "which -a ls should start with builtin: ls"
410 }
411 }
412}
413
414pub fn eval_which_external_test() {
415 let env = env.new()
416 // `sh` is not a gleshell builtin; should resolve via PATH.
417 let assert eval.Continue(_, String(path)) = eval.eval_source(env, "which sh")
418 let assert True = string.contains(path, "sh")
419 Nil
420}
421
422pub fn value_nothing_falsey_test() {
423 let assert False = value.is_truthy(Nothing)
424 let assert True = value.is_truthy(Int(1))
425 Nil
426}
427
428// --- display / color ---
429
430pub fn display_plain_no_ansi_test() {
431 let text = display.render_with(False, Bool(True))
432 let assert "true" = text
433 let assert False = string_contains(text, "\u{001b}")
434 Nil
435}
436
437pub fn display_colored_has_ansi_test() {
438 let text = display.render_with(True, Bool(True))
439 let assert True = string_contains(text, "\u{001b}")
440 let assert True = string_contains(text, "true")
441 Nil
442}
443
444pub fn display_preserves_external_ansi_test() {
445 // Pre-colored tool output (jj, git, …) must not be re-wrapped in string green.
446 let app = "\u{001b}[1;31merror\u{001b}[0m: boom"
447 let text = display.render_with(True, String(app))
448 let assert True = text == app
449 Nil
450}
451
452pub fn display_multiline_string_not_recolored_test() {
453 let multi = "line1\nline2"
454 let text = display.render_with(True, String(multi))
455 let assert True = text == multi
456 Nil
457}
458
459pub fn display_table_headers_colored_test() {
460 let text =
461 display.render_with(
462 True,
463 Table(["name", "type"], [[String("src"), String("dir")]]),
464 )
465 // bold green header + bright-blue dir name
466 let assert True = string_contains(text, "\u{001b}[1;32m")
467 let assert True = string_contains(text, "\u{001b}[94m")
468 let assert True = string_contains(text, "src")
469 Nil
470}
471
472pub fn format_filesize_units_test() {
473 let assert "0 B" = display.format_filesize(0)
474 let assert "512 B" = display.format_filesize(512)
475 let assert "1023 B" = display.format_filesize(1023)
476 let assert "1 KB" = display.format_filesize(1024)
477 let assert "1.5 KB" = display.format_filesize(1536)
478 let assert "1 MB" = display.format_filesize(1_048_576)
479 let assert "1.5 MB" = display.format_filesize(1_048_576 + 524_288)
480 let assert "1 GB" = display.format_filesize(1_073_741_824)
481 Nil
482}
483
484pub fn display_size_column_humanized_test() {
485 // Data stays as raw bytes (Int); only display shows KB/MB.
486 let text =
487 display.render_with(
488 False,
489 Table(["name", "size"], [[String("a"), Int(2048)]]),
490 )
491 let assert True = string_contains(text, "2 KB")
492 let assert False = string_contains(text, "2048")
493 Nil
494}
495
496pub fn color_visible_length_strips_ansi_test() {
497 let painted = color.paint(True, "\u{001b}[32m", "hi")
498 let assert 2 = color.visible_length(painted)
499 let assert 2 = color.visible_length("hi")
500 Nil
501}
502
503// --- input syntax highlighting ---
504
505pub fn highlight_plain_when_off_test() {
506 let src = "ls | where type == file"
507 let assert True = highlight.highlight(False, src) == src
508 Nil
509}
510
511pub fn highlight_pipeline_has_shapes_test() {
512 let text = highlight.highlight(True, "ls | first 3")
513 // bold cyan internalcall, bold purple pipe, bold purple int
514 let assert True = string_contains(text, "\u{001b}[1;36m")
515 let assert True = string_contains(text, "\u{001b}[1;35m")
516 let assert True = string_contains(text, "ls")
517 let assert True = string_contains(text, "first")
518 let assert True = string_contains(text, "3")
519 // visible text unchanged
520 let assert 12 = color.visible_length(text)
521 Nil
522}
523
524pub fn highlight_string_and_flag_test() {
525 let text = highlight.highlight(True, "echo \"hi\" --raw")
526 let assert True = string_contains(text, "\u{001b}[32m")
527 let assert True = string_contains(text, "\u{001b}[1;34m")
528 let assert True = string_contains(text, "hi")
529 let assert True = string_contains(text, "--raw")
530 Nil
531}
532
533pub fn highlight_variable_and_let_test() {
534 let text = highlight.highlight(True, "let x = $in")
535 let assert True = string_contains(text, "\u{001b}[1;36m")
536 let assert True = string_contains(text, "\u{001b}[35m")
537 let assert True = string_contains(text, "let")
538 let assert True = string_contains(text, "$in")
539 Nil
540}
541
542pub fn highlight_incomplete_string_test() {
543 // Unterminated string while typing should not crash
544 let text = highlight.highlight(True, "echo \"hel")
545 let assert True = string_contains(text, "hel")
546 let assert True = string_contains(text, "\u{001b}[32m")
547 Nil
548}
549
550pub fn highlight_to_command_test() {
551 // `to` is a builtin; `json` is a subcommand arg
552 let text = highlight.highlight(True, "range 3 | to json")
553 let assert True = string_contains(text, "to")
554 let assert True = string_contains(text, "json")
555 let assert True = string_contains(text, "\u{001b}[1;36m")
556 Nil
557}
558
559pub fn highlight_path_arg_not_garbage_test() {
560 // Dotfile paths must not use shape_garbage (white-on-red)
561 let text = highlight.highlight(True, "ls .jj ./src /tmp ~/code")
562 let assert True = string_contains(text, ".jj")
563 let assert False = string_contains(text, "\u{001b}[1;37;41m")
564 // args use shape_externalarg (bold green)
565 let assert True = string_contains(text, "\u{001b}[1;32m")
566 Nil
567}
568
569fn string_contains(haystack: String, needle: String) -> Bool {
570 case string.split(haystack, needle) {
571 [_] -> False
572 _ -> True
573 }
574}
575
576// --- tab completion ---
577
578pub fn complete_command_builtin_test() {
579 // Start of line: complete builtins
580 let #(matches, kind) = sys.complete_word("", "ech")
581 let assert True = kind == "command"
582 let assert True = list_contains(matches, "echo")
583 // After pipeline separator
584 let #(matches2, kind2) = sys.complete_word("ls | ", "wher")
585 let assert True = kind2 == "command"
586 let assert True = list_contains(matches2, "where")
587 // `to` / `from` are single-word commands (subcommand is a normal arg)
588 let #(matches3, kind3) = sys.complete_word("", "to")
589 let assert True = kind3 == "command"
590 let assert True = list_contains(matches3, "to")
591 let #(matches_from, kind_from) = sys.complete_word("", "fro")
592 let assert True = kind_from == "command"
593 let assert True = list_contains(matches_from, "from")
594 // Keyword
595 let #(matches4, kind4) = sys.complete_word("", "le")
596 let assert True = kind4 == "command"
597 let assert True = list_contains(matches4, "let")
598 Nil
599}
600
601pub fn complete_command_after_assign_test() {
602 let #(matches, kind) = sys.complete_word("let x = ", "ran")
603 let assert True = kind == "command"
604 let assert True = list_contains(matches, "range")
605 Nil
606}
607
608pub fn complete_path_for_args_test() {
609 // After a command name, Tab completes files not commands
610 let #(_matches, kind) = sys.complete_word("echo ", "ech")
611 let assert True = kind == "path"
612 Nil
613}
614
615pub fn complete_path_like_command_test() {
616 // Path-shaped command words stay on filename completion
617 let #(_matches, kind) = sys.complete_word("", "./ec")
618 let assert True = kind == "path"
619 let #(_matches2, kind2) = sys.complete_word("", "/us")
620 let assert True = kind2 == "path"
621 Nil
622}
623
624pub fn complete_path_executable_test() {
625 // PATH + builtins for a non-empty prefix
626 let #(matches, kind) = sys.complete_word("", "s")
627 let assert True = kind == "command"
628 // At least builtins starting with s (select, skip, sort-by, …)
629 let assert True = list_contains(matches, "select")
630 Nil
631}
632
633fn list_contains(items: List(String), needle: String) -> Bool {
634 case items {
635 [] -> False
636 [x, ..] if x == needle -> True
637 [_, ..rest] -> list_contains(rest, needle)
638 }
639}