A structured-data shell in Gleam, inspired by Nushell
1import gleam/list
2import gleam/option.{None, Some}
3import gleam/string
4import gleeunit
5import gleshell/builtins
6import gleshell/color
7import gleshell/display
8import gleshell/env
9import gleshell/eval
10import gleshell/highlight
11import gleshell/lexer
12import gleshell/pager
13import gleshell/parser
14import gleshell/syntax
15import gleshell/sys
16import gleshell/value.{Bool, Int, List, Nothing, Record, String, Table}
17import simplifile
18
19pub fn main() -> Nil {
20 gleeunit.main()
21}
22
23// --- lexer ---
24
25pub fn lexer_pipeline_test() {
26 let assert Ok(tokens) = lexer.tokenize("ls | where type == file")
27 let assert [lexer.Ident("ls"), lexer.Pipe, lexer.Ident("where"), ..] = tokens
28 Nil
29}
30
31pub fn lexer_string_and_number_test() {
32 let assert Ok(tokens) = lexer.tokenize("echo \"hi\" 42 3.14 true")
33 let assert [
34 lexer.Ident("echo"),
35 lexer.StringLit("hi"),
36 lexer.IntLit(42),
37 lexer.FloatLit(3.14),
38 lexer.BoolLit(True),
39 lexer.Eof,
40 ] = tokens
41 Nil
42}
43
44pub fn lexer_path_idents_test() {
45 // Dotfiles, relative/absolute paths, home — must be bare words for `ls .jj` etc.
46 let assert Ok(tokens) = lexer.tokenize("ls .jj ./src ../foo /tmp ~ ~/code")
47 let assert [
48 lexer.Ident("ls"),
49 lexer.Ident(".jj"),
50 lexer.Ident("./src"),
51 lexer.Ident("../foo"),
52 lexer.Ident("/tmp"),
53 lexer.Ident("~"),
54 lexer.Ident("~/code"),
55 lexer.Eof,
56 ] = tokens
57 Nil
58}
59
60pub fn lexer_bare_double_dash_test() {
61 // POSIX end-of-options: `nix run . -- args` must not lex-error on bare `--`.
62 let assert Ok(tokens) = lexer.tokenize("nix run . -- chadfowler.com yolo")
63 let assert [
64 lexer.Ident("nix"),
65 lexer.Ident("run"),
66 lexer.Ident("."),
67 lexer.Flag(""),
68 lexer.Ident("chadfowler.com"),
69 lexer.Ident("yolo"),
70 lexer.Eof,
71 ] = tokens
72 // Named long flags still work
73 let assert Ok(tokens2) = lexer.tokenize("cmd --think --scale 3")
74 let assert [
75 lexer.Ident("cmd"),
76 lexer.Flag("think"),
77 lexer.Flag("scale"),
78 lexer.IntLit(3),
79 lexer.Eof,
80 ] = tokens2
81 Nil
82}
83
84pub fn parse_bare_double_dash_test() {
85 let assert Ok(parser.Expr(parser.Pipeline([
86 parser.Command("nix", args, False),
87 ]))) = parser.parse("nix run . -- chadfowler.com yolo")
88 let assert [
89 parser.ValueArg(parser.Lit(String("run"))),
90 parser.ValueArg(parser.Lit(String("."))),
91 parser.ValueArg(parser.Lit(String("--"))),
92 parser.ValueArg(parser.Lit(String("chadfowler.com"))),
93 parser.ValueArg(parser.Lit(String("yolo"))),
94 ] = args
95 Nil
96}
97
98pub fn parse_ls_dotfile_test() {
99 let assert Ok(parser.Expr(parser.Pipeline([
100 parser.Command("ls", args, False),
101 ]))) = parser.parse("ls .jj")
102 let assert [parser.ValueArg(parser.Lit(String(".jj")))] = args
103 Nil
104}
105
106pub fn parse_port_spec_arg_test() {
107 // `lsof -i :4004` — colon is a record token but must be a bare argv word here.
108 let assert Ok(parser.Expr(parser.Pipeline([
109 parser.Command("lsof", args, False),
110 ]))) = parser.parse("lsof -i :4004")
111 let assert [
112 parser.FlagArg("i", parser.None),
113 parser.ValueArg(parser.Lit(String(":4004"))),
114 ] = args
115 // No space: `lsof -i:4004`
116 let assert Ok(parser.Expr(parser.Pipeline([
117 parser.Command("lsof", args2, False),
118 ]))) = parser.parse("lsof -i:4004")
119 let assert [
120 parser.FlagArg("i", parser.None),
121 parser.ValueArg(parser.Lit(String(":4004"))),
122 ] = args2
123 // Glued host:port and URL-shaped words
124 let assert Ok(parser.Expr(parser.Pipeline([
125 parser.Command("echo", args3, False),
126 ]))) = parser.parse("echo host:4004 http://example.com")
127 let assert [
128 parser.ValueArg(parser.Lit(String("host:4004"))),
129 parser.ValueArg(parser.Lit(String("http://example.com"))),
130 ] = args3
131 Nil
132}
133
134// --- parser ---
135
136pub fn parse_pipeline_test() {
137 let assert Ok(parser.Expr(parser.Pipeline(cmds))) =
138 parser.parse("ls | first 3")
139 let assert [
140 parser.Command("ls", [], False),
141 parser.Command("first", args, False),
142 ] = cmds
143 let assert [parser.ValueArg(parser.Lit(Int(3)))] = args
144 Nil
145}
146
147pub fn parse_let_test() {
148 let assert Ok(parser.Let(
149 "x",
150 parser.Pipeline([parser.Command("echo", args, False)]),
151 )) = parser.parse("let x = echo 1")
152 let assert [parser.ValueArg(parser.Lit(Int(1)))] = args
153 Nil
154}
155
156pub fn parse_where_ops_test() {
157 let assert Ok(parser.Expr(parser.Pipeline([
158 parser.Command("where", args, False),
159 ]))) = parser.parse("where type == file")
160 let assert [
161 parser.ValueArg(parser.Lit(String("type"))),
162 parser.ValueArg(parser.Lit(String("=="))),
163 parser.ValueArg(parser.Lit(String("file"))),
164 ] = args
165 Nil
166}
167
168pub fn parse_list_and_record_test() {
169 let assert Ok(parser.Expr(parser.Pipeline([
170 parser.Command("echo", args, False),
171 ]))) = parser.parse("echo [1 2] {a: true}")
172 let assert [
173 parser.ValueArg(parser.ListExpr([parser.Lit(Int(1)), parser.Lit(Int(2))])),
174 parser.ValueArg(parser.RecordExpr([#("a", parser.Lit(Bool(True)))])),
175 ] = args
176 Nil
177}
178
179// --- value helpers ---
180
181pub fn table_from_records_test() {
182 let rows = [
183 Record([#("name", String("a")), #("n", Int(1))]),
184 Record([#("name", String("b")), #("n", Int(2))]),
185 ]
186 let assert Table(
187 ["name", "n"],
188 [[String("a"), Int(1)], [String("b"), Int(2)]],
189 ) = value.table_from_records(rows)
190 Nil
191}
192
193// --- eval ---
194
195pub fn eval_echo_and_range_test() {
196 let env = env.new()
197 let assert eval.Continue(_, List([Int(0), Int(1), Int(2)])) =
198 eval.eval_source(env, "range 3")
199 let assert eval.Continue(_, String("hello")) =
200 eval.eval_source(env, "echo hello")
201 Nil
202}
203
204pub fn eval_pipeline_reverse_first_test() {
205 let env = env.new()
206 let assert eval.Continue(_, Int(2)) =
207 eval.eval_source(env, "range 3 | reverse | first")
208 Nil
209}
210
211/// Pipeline input must become the external's stdin (`cat f | less`, `echo hi | wc`).
212/// Use `let` so the last stage is capture mode even on a TTY (bare expressions
213/// may inherit the terminal and return an empty string value).
214pub fn eval_pipeline_stdin_to_external_test() {
215 let env = env.new()
216 // `echo` is a builtin; `wc` is external. Count bytes of "hello" (no trailing NL).
217 let assert eval.Continue(_, String(out)) =
218 eval.eval_source(env, "let n = echo hello | ^wc -c")
219 let assert True = string.contains(out, "5")
220 // Tight `cmd|cmd` form (no spaces around pipe)
221 let assert eval.Continue(_, String(out2)) =
222 eval.eval_source(env, "let m = echo ab|^wc -c")
223 let assert True = string.contains(out2, "2")
224 Nil
225}
226
227pub fn eval_let_and_var_test() {
228 let env = env.new()
229 let assert eval.Continue(env2, Int(7)) =
230 eval.eval_source(env, "let n = echo 7")
231 let assert eval.Continue(_, Int(7)) = eval.eval_source(env2, "echo $n")
232 Nil
233}
234
235pub fn eval_env_var_get_test() {
236 // `$env.HOME` and bare `$env.HOME` as pipeline source
237 let env = env.new()
238 let assert Ok(home) = sys.getenv("HOME")
239 let assert eval.Continue(_, String(got)) =
240 eval.eval_source(env, "echo $env.HOME")
241 let assert True = got == home
242 let assert eval.Continue(_, String(got2)) = eval.eval_source(env, "$env.HOME")
243 let assert True = got2 == home
244 Nil
245}
246
247pub fn eval_env_record_test() {
248 let env = env.new()
249 let assert eval.Continue(_, Record(fields)) = eval.eval_source(env, "$env")
250 let assert True = list_has_string_field(fields, "HOME")
251 let assert True = list_has_string_field(fields, "PATH")
252 // `$env | get HOME`
253 let assert eval.Continue(_, String(home)) =
254 eval.eval_source(env, "$env | get HOME")
255 let assert True = string.length(home) > 0
256 Nil
257}
258
259pub fn eval_env_assign_test() {
260 let env = env.new()
261 let assert eval.Continue(env2, String("gleshell-test-val")) =
262 eval.eval_source(env, "$env.GLESHELL_TEST_VAR = gleshell-test-val")
263 let assert eval.Continue(_, String("gleshell-test-val")) =
264 eval.eval_source(env2, "$env.GLESHELL_TEST_VAR")
265 let assert Ok("gleshell-test-val") = sys.getenv("GLESHELL_TEST_VAR")
266 Nil
267}
268
269pub fn parse_env_assign_test() {
270 let assert Ok(parser.EnvAssign("FOO", parser.Pipeline([cmd]))) =
271 parser.parse("$env.FOO = hello")
272 let assert parser.Command(
273 "__value__",
274 [parser.ValueArg(parser.Lit(String("hello")))],
275 False,
276 ) = cmd
277 Nil
278}
279
280fn list_has_string_field(
281 fields: List(#(String, value.Value)),
282 key: String,
283) -> Bool {
284 case fields {
285 [] -> False
286 [#(k, String(_)), ..] if k == key -> True
287 [_, ..rest] -> list_has_string_field(rest, key)
288 }
289}
290
291pub fn eval_where_select_test() {
292 let env = env.new()
293 // build table via records in a list, convert with table
294 let assert eval.Continue(_, result) =
295 eval.eval_source(
296 env,
297 "echo [{name: a, n: 1} {name: b, n: 2} {name: c, n: 3}] | table | where n > 1 | select name",
298 )
299 let assert Table(["name"], rows) = result
300 let assert [[String("b")], [String("c")]] = rows
301 Nil
302}
303
304pub fn about_command_test() {
305 let env = env.new()
306 let assert eval.Continue(_, String(text)) = eval.eval_source(env, "about")
307 let assert True = string.contains(text, "gleshell")
308 let assert True = string.contains(text, "nandi.uk")
309 let assert True = string.contains(text, "NaNdi")
310 let assert True = string.contains(text, "did:plc:ngokl2gnmpbvuvrfckja3g7p")
311 let assert True = string.contains(text, "latha.org")
312 let assert eval.Continue(_, String(which_out)) =
313 eval.eval_source(env, "which about")
314 let assert "builtin: about" = which_out
315 Nil
316}
317
318pub fn help_covers_all_builtins_test() {
319 // Every registered builtin must have a dedicated help_text entry.
320 let assert [] = builtins.missing_help()
321
322 let env = env.new()
323 // which finds builtins; help must too (not "unknown command")
324 let assert eval.Continue(_, String(which_out)) =
325 eval.eval_source(env, "which table")
326 let assert "builtin: table" = which_out
327 let assert eval.Continue(_, String(help_out)) =
328 eval.eval_source(env, "help table")
329 let assert True = string.contains(help_out, "table")
330 let assert True = string.contains(help_out, "coerce")
331
332 // Parent commands with subcommands: `help to` / `help from` / `help http`.
333 let assert eval.Continue(_, String(to_help)) =
334 eval.eval_source(env, "help to")
335 let assert True = string.contains(to_help, "json")
336 let assert eval.Continue(_, String(from_help)) =
337 eval.eval_source(env, "help from")
338 let assert True = string.contains(from_help, "json")
339 let assert eval.Continue(_, String(http_help)) =
340 eval.eval_source(env, "help http")
341 let assert True = string.contains(http_help, "get")
342 let assert True = string.contains(http_help, "post")
343
344 // Bare help lists every command with its one-line description.
345 let assert eval.Continue(_, String(all)) = eval.eval_source(env, "help")
346 list.each(builtins.names(), fn(name) {
347 let assert True = string.contains(all, name)
348 })
349
350 let assert eval.Continue(env2, value.Fail(msg)) =
351 eval.eval_source(env, "help not-a-real-cmd")
352 let assert True = string.contains(msg, "unknown command")
353 let assert 1 = env2.last_exit
354 Nil
355}
356
357pub fn eval_from_json_test() {
358 let env = env.new()
359 let assert eval.Continue(_, Record(fields)) =
360 eval.eval_source(env, "echo \"{\\\"x\\\": 1}\" | from json")
361 let assert True = list_has_field(fields, "x", Int(1))
362 Nil
363}
364
365pub fn eval_to_json_pretty_test() {
366 let env = env.new()
367 // `to` command + `json` subcommand — pretty by default
368 let assert eval.Continue(_, String(pretty)) =
369 eval.eval_source(env, "echo [1 2 3] | to json")
370 let assert True = string.contains(pretty, "\n")
371 let assert True = string.contains(pretty, "1")
372 // `--raw` matches Nu: compact, no trailing newline
373 let assert eval.Continue(_, String(raw)) =
374 eval.eval_source(env, "echo [1 2 3] | to json --raw")
375 let assert "[1,2,3]" = raw
376 // Missing / unknown subcommand
377 let assert eval.Continue(env2, value.Fail(msg)) =
378 eval.eval_source(env, "echo 1 | to")
379 let assert True = string.contains(msg, "subcommand")
380 let assert 1 = env2.last_exit
381 let assert eval.Continue(_, value.Fail(msg2)) =
382 eval.eval_source(env, "echo 1 | to yaml")
383 let assert True = string.contains(msg2, "unknown subcommand")
384 Nil
385}
386
387pub fn eval_to_json_record_test() {
388 let env = env.new()
389 let assert eval.Continue(_, String(raw)) =
390 eval.eval_source(env, "echo {a: 1, b: true} | to json -r")
391 let assert True = string.contains(raw, "\"a\":1")
392 let assert True = string.contains(raw, "\"b\":true")
393 Nil
394}
395
396pub fn http_subcommand_errors_test() {
397 let env = env.new()
398 // Missing subcommand
399 let assert eval.Continue(env2, value.Fail(msg)) =
400 eval.eval_source(env, "http")
401 let assert True = string.contains(msg, "subcommand")
402 let assert 1 = env2.last_exit
403 // Unknown subcommand
404 let assert eval.Continue(_, value.Fail(msg2)) =
405 eval.eval_source(env, "http foo")
406 let assert True = string.contains(msg2, "unknown subcommand")
407 // Missing URL
408 let assert eval.Continue(_, value.Fail(msg3)) =
409 eval.eval_source(env, "http get")
410 let assert True = string.contains(msg3, "URL") || string.contains(msg3, "url")
411 // Invalid URL
412 let assert eval.Continue(_, value.Fail(msg4)) =
413 eval.eval_source(env, "http get not-a-url")
414 let assert True = string.contains(msg4, "invalid URL")
415 // which / help
416 let assert eval.Continue(_, String(which_out)) =
417 eval.eval_source(env, "which http")
418 let assert "builtin: http" = which_out
419 Nil
420}
421
422pub fn http_get_live_test() {
423 // Live request against postman-echo (JSON). Skip gracefully if offline.
424 let env = env.new()
425 case eval.eval_source(env, "http get --full https://postman-echo.com/get") {
426 eval.Continue(_, Record(fields)) -> {
427 let assert True = list_has_field(fields, "status", Int(200))
428 let assert True = list_has_key(fields, "body")
429 let assert True = list_has_key(fields, "headers")
430 let assert True =
431 list_has_field(fields, "url", String("https://postman-echo.com/get"))
432 // Default path (no --full) parses JSON body into a record
433 let assert eval.Continue(_, Record(body_fields)) =
434 eval.eval_source(env, "http get https://postman-echo.com/get")
435 let assert True = list_has_key(body_fields, "url")
436 Nil
437 }
438 eval.Continue(_, value.Fail(msg)) -> {
439 // Network unavailable — still assert the error is from http, not parse
440 let assert True =
441 string.contains(msg, "http:") || string.contains(msg, "failed")
442 Nil
443 }
444 _ -> panic as "http get --full: unexpected eval result"
445 }
446}
447
448pub fn http_post_json_live_test() {
449 let env = env.new()
450 case
451 eval.eval_source(
452 env,
453 "http post --full https://postman-echo.com/post {name: gleshell}",
454 )
455 {
456 eval.Continue(_, Record(fields)) -> {
457 let assert True = list_has_field(fields, "status", Int(200))
458 // JSON body should be parsed; postman-echo echoes under `json`
459 case list_find_field(fields, "body") {
460 Ok(Record(body)) -> {
461 case list_find_field(body, "json") {
462 Ok(Record(json_fields)) -> {
463 let assert True =
464 list_has_field(json_fields, "name", String("gleshell"))
465 Nil
466 }
467 _ -> Nil
468 }
469 }
470 _ -> Nil
471 }
472 }
473 eval.Continue(_, value.Fail(_)) -> Nil
474 _ -> panic as "http post --full: unexpected eval result"
475 }
476}
477
478fn list_has_key(fields: List(#(String, value.Value)), key: String) -> Bool {
479 case fields {
480 [] -> False
481 [#(k, _), ..rest] ->
482 case k == key {
483 True -> True
484 False -> list_has_key(rest, key)
485 }
486 }
487}
488
489fn list_find_field(
490 fields: List(#(String, value.Value)),
491 key: String,
492) -> Result(value.Value, Nil) {
493 case fields {
494 [] -> Error(Nil)
495 [#(k, v), ..rest] ->
496 case k == key {
497 True -> Ok(v)
498 False -> list_find_field(rest, key)
499 }
500 }
501}
502
503fn list_has_field(
504 fields: List(#(String, value.Value)),
505 key: String,
506 expected: value.Value,
507) -> Bool {
508 case fields {
509 [] -> False
510 [#(k, v), ..rest] ->
511 case k == key {
512 True -> value.equals(v, expected)
513 False -> list_has_field(rest, key, expected)
514 }
515 }
516}
517
518pub fn eval_length_test() {
519 let env = env.new()
520 let assert eval.Continue(_, Int(4)) =
521 eval.eval_source(env, "range 4 | length")
522 Nil
523}
524
525pub fn eval_which_builtin_test() {
526 let env = env.new()
527 let assert eval.Continue(_, String("builtin: ls")) =
528 eval.eval_source(env, "which ls")
529 Nil
530}
531
532pub fn eval_find_list_test() {
533 let env = env.new()
534 // Substring match (OR across terms)
535 let assert eval.Continue(_, List(items)) =
536 eval.eval_source(env, "echo [moe larry curly] | find l")
537 let assert [String("larry"), String("curly")] = items
538 // Multiple terms
539 let assert eval.Continue(_, List(items2)) =
540 eval.eval_source(env, "echo [a.toml b.md c.rs] | find toml md")
541 let assert [String("a.toml"), String("b.md")] = items2
542 // Exact number match
543 let assert eval.Continue(_, List([Int(5)])) =
544 eval.eval_source(env, "echo [1 5 3 4 35] | find 5")
545 Nil
546}
547
548pub fn eval_find_ignore_case_invert_test() {
549 let env = env.new()
550 let assert eval.Continue(_, List(items)) =
551 eval.eval_source(env, "echo [Hello world HELLO] | find hello -i")
552 let assert [String("Hello"), String("HELLO")] = items
553 // `-i term` may attach term to the flag; still works
554 let assert eval.Continue(_, List(items2)) =
555 eval.eval_source(env, "echo [Hello world HELLO] | find -i hello")
556 let assert [String("Hello"), String("HELLO")] = items2
557 let assert eval.Continue(_, List([String("cd")])) =
558 eval.eval_source(env, "echo [ab cd] | find --invert a")
559 Nil
560}
561
562pub fn eval_find_table_and_string_test() {
563 let env = env.new()
564 let assert eval.Continue(_, Table(["name", "type"], rows)) =
565 eval.eval_source(
566 env,
567 "echo [{name: a, type: file} {name: b, type: dir}] | table | find file",
568 )
569 let assert [[String("a"), String("file")]] = rows
570 // Single-line string: return the string if match, else nothing
571 let assert eval.Continue(_, String("Cargo.toml")) =
572 eval.eval_source(env, "echo Cargo.toml | find Cargo")
573 let assert eval.Continue(_, Nothing) =
574 eval.eval_source(env, "echo Cargo.toml | find zz")
575 // Multi-line string → list of matching lines
576 let assert eval.Continue(_, List(lines)) =
577 eval.eval_source(env, "echo \"hi\nbye\nhi there\" | find hi")
578 let assert [String("hi"), String("hi there")] = lines
579 Nil
580}
581
582pub fn eval_find_regex_test() {
583 let env = env.new()
584 let assert eval.Continue(_, List(items)) =
585 eval.eval_source(env, "echo [abc odb arc abf] | find --regex \"b.\"")
586 let assert [String("abc"), String("abf")] = items
587 // Regex + terms is rejected (Nu-compatible)
588 let assert eval.Continue(env2, value.Fail(msg)) =
589 eval.eval_source(env, "echo [abc] | find --regex \"b.\" x")
590 let assert True = string.contains(msg, "regex")
591 let assert 1 = env2.last_exit
592 Nil
593}
594
595pub fn eval_which_all_test() {
596 let env = env.new()
597 // `-a` includes the builtin first, then any PATH copies of `ls`.
598 let assert eval.Continue(_, result) = eval.eval_source(env, "which -a ls")
599 case result {
600 String("builtin: ls") -> Nil
601 List([String("builtin: ls"), ..]) -> Nil
602 other -> {
603 let _ = other
604 panic as "which -a ls should start with builtin: ls"
605 }
606 }
607}
608
609pub fn eval_which_external_test() {
610 let env = env.new()
611 // `sh` is not a gleshell builtin; should resolve via PATH.
612 let assert eval.Continue(_, String(path)) = eval.eval_source(env, "which sh")
613 let assert True = string.contains(path, "sh")
614 Nil
615}
616
617pub fn value_nothing_falsey_test() {
618 let assert False = value.is_truthy(Nothing)
619 let assert True = value.is_truthy(Int(1))
620 Nil
621}
622
623// --- display / color ---
624
625pub fn display_plain_no_ansi_test() {
626 let text = display.render_with(False, Bool(True))
627 let assert "true" = text
628 let assert False = string_contains(text, "\u{001b}")
629 Nil
630}
631
632pub fn display_colored_has_ansi_test() {
633 let text = display.render_with(True, Bool(True))
634 let assert True = string_contains(text, "\u{001b}")
635 let assert True = string_contains(text, "true")
636 Nil
637}
638
639pub fn display_preserves_external_ansi_test() {
640 // Pre-colored tool output (jj, git, …) must not be re-wrapped in string green.
641 let app = "\u{001b}[1;31merror\u{001b}[0m: boom"
642 let text = display.render_with(True, String(app))
643 let assert True = text == app
644 Nil
645}
646
647pub fn display_multiline_string_not_recolored_test() {
648 let multi = "line1\nline2"
649 let text = display.render_with(True, String(multi))
650 let assert True = text == multi
651 Nil
652}
653
654pub fn display_table_headers_colored_test() {
655 let text =
656 display.render_with(
657 True,
658 Table(["name", "type"], [[String("src"), String("dir")]]),
659 )
660 // bold green header + bright-blue dir name
661 let assert True = string_contains(text, "\u{001b}[1;32m")
662 let assert True = string_contains(text, "\u{001b}[94m")
663 let assert True = string_contains(text, "src")
664 Nil
665}
666
667pub fn format_filesize_units_test() {
668 let assert "0 B" = display.format_filesize(0)
669 let assert "512 B" = display.format_filesize(512)
670 let assert "1023 B" = display.format_filesize(1023)
671 let assert "1 KB" = display.format_filesize(1024)
672 let assert "1.5 KB" = display.format_filesize(1536)
673 let assert "1 MB" = display.format_filesize(1_048_576)
674 let assert "1.5 MB" = display.format_filesize(1_048_576 + 524_288)
675 let assert "1 GB" = display.format_filesize(1_073_741_824)
676 Nil
677}
678
679pub fn display_size_column_humanized_test() {
680 // Data stays as raw bytes (Int); only display shows KB/MB.
681 let text =
682 display.render_with(
683 False,
684 Table(["name", "size"], [[String("a"), Int(2048)]]),
685 )
686 let assert True = string_contains(text, "2 KB")
687 let assert False = string_contains(text, "2048")
688 Nil
689}
690
691pub fn color_visible_length_strips_ansi_test() {
692 let painted = color.paint(True, "\u{001b}[32m", "hi")
693 let assert 2 = color.visible_length(painted)
694 let assert 2 = color.visible_length("hi")
695 Nil
696}
697
698// --- less / pager ---
699
700pub fn pager_wrap_respects_ansi_width_test() {
701 // 10 visible chars of content; wrap at 4 → three physical lines.
702 let painted = color.paint(True, "\u{001b}[32m", "abcdefghij")
703 let lines = pager.wrap_line(painted, 4)
704 let assert 3 = list.length(lines)
705 // Each physical line still carries / continues color; visible width ≤ 4.
706 list.each(lines, fn(line) {
707 let assert True = color.visible_length(line) <= 4
708 })
709 // Joining without separators reconstructs the original SGR + text.
710 let joined = string.join(lines, "")
711 let assert True = string.contains(joined, "abcdefghij")
712 let assert True = string.contains(joined, "\u{001b}[32m")
713 Nil
714}
715
716pub fn pager_display_lines_splits_newlines_test() {
717 let lines = pager.display_lines("a\nb\nc", 80)
718 let assert ["a", "b", "c"] = lines
719 Nil
720}
721
722pub fn strip_ansi_removes_csi_test() {
723 let painted = color.paint(True, "\u{001b}[32m", "hello")
724 let assert "hello" = color.strip_ansi(painted)
725 let assert "plain" = color.strip_ansi("plain")
726 let assert "ab" = color.strip_ansi("a\u{001b}[1;31mb\u{001b}[0m")
727 Nil
728}
729
730pub fn pager_line_matches_strips_ansi_test() {
731 let painted = color.paint(True, "\u{001b}[31m", "needle")
732 let assert True = pager.line_matches(painted, "needle")
733 let assert True = pager.line_matches(painted, "eed")
734 // Case-insensitive: mixed / upper pattern still hits.
735 let assert True = pager.line_matches(painted, "NEEDLE")
736 let assert True = pager.line_matches(painted, "NeEd")
737 let assert False = pager.line_matches(painted, "")
738 // Pattern must not match inside CSI itself.
739 let assert False = pager.line_matches(painted, "[31m")
740 Nil
741}
742
743pub fn pager_find_after_and_before_test() {
744 let lines = ["alpha", "bravo", "alpha", "charlie"]
745 let assert Ok(#(0, False)) = pager.find_after(lines, "alpha", -1)
746 let assert Ok(#(2, False)) = pager.find_after(lines, "alpha", 0)
747 // Wrap from end back to first match.
748 let assert Ok(#(0, True)) = pager.find_after(lines, "alpha", 2)
749 let assert Error(Nil) = pager.find_after(lines, "zzz", -1)
750 // Case-insensitive search.
751 let assert Ok(#(0, False)) = pager.find_after(lines, "ALPHA", -1)
752 let assert Ok(#(2, False)) = pager.find_after(lines, "AlPhA", 0)
753
754 let assert Ok(#(2, False)) = pager.find_before(lines, "alpha", 3)
755 let assert Ok(#(0, False)) = pager.find_before(lines, "alpha", 2)
756 // Wrap from start back to last match.
757 let assert Ok(#(2, True)) = pager.find_before(lines, "alpha", 0)
758 let assert Ok(#(2, False)) = pager.find_before(lines, "ALPHA", 3)
759 Nil
760}
761
762pub fn pager_find_empty_pattern_test() {
763 let lines = ["a", "b"]
764 let assert Error(Nil) = pager.find_after(lines, "", -1)
765 let assert Error(Nil) = pager.find_before(lines, "", 2)
766 Nil
767}
768
769pub fn pager_live_search_preview_test() {
770 let lines = ["alpha", "bravo", "charlie", "alpha again"]
771 // Empty query: stay put, no highlight.
772 let assert #(2, None, None) = pager.live_search_preview(lines, "", 2)
773 // Inclusive of start_offset (match on current top line).
774 let assert #(0, Some("alpha"), None) =
775 pager.live_search_preview(lines, "alpha", 0)
776 // From mid-buffer: first match at or after start.
777 let assert #(3, Some("alpha"), None) =
778 pager.live_search_preview(lines, "alpha", 1)
779 // Not found: keep start offset, still paint pattern, status suffix.
780 let assert #(1, Some("zzz"), Some("not found")) =
781 pager.live_search_preview(lines, "zzz", 1)
782 // Progressive typing narrows: "ch" → charlie.
783 let assert #(2, Some("ch"), None) = pager.live_search_preview(lines, "ch", 0)
784 // Case-insensitive: upper pattern still finds lower content.
785 let assert #(0, Some("ALPHA"), None) =
786 pager.live_search_preview(lines, "ALPHA", 0)
787 let assert #(3, Some("Alpha"), None) =
788 pager.live_search_preview(lines, "Alpha", 1)
789 Nil
790}
791
792pub fn pager_highlight_matches_test() {
793 let out = pager.highlight_matches("hello world", "world")
794 // Black on bright yellow accent.
795 let assert True = string.contains(out, "\u{001b}[30;103m")
796 let assert True = string.contains(out, "world")
797 let assert True = string.contains(out, "\u{001b}[39;49m")
798 let assert "hello world" = color.strip_ansi(out)
799
800 // Case-insensitive highlight preserves original casing in the line.
801 let ci = pager.highlight_matches("Hello World", "WORLD")
802 let assert True = string.contains(ci, "\u{001b}[30;103m")
803 let assert True = string.contains(ci, "World")
804 let assert "Hello World" = color.strip_ansi(ci)
805
806 // No match / empty pattern: unchanged.
807 let assert "nope" = pager.highlight_matches("nope", "zzz")
808 let assert "x" = pager.highlight_matches("x", "")
809
810 // Multiple non-overlapping hits.
811 let multi = pager.highlight_matches("aa x aa", "aa")
812 let parts = string.split(multi, "\u{001b}[30;103m")
813 let assert 3 = list.length(parts)
814
815 // ANSI around the match is preserved; highlight still finds visible text.
816 let painted = color.paint(True, "\u{001b}[32m", "needle here")
817 let hi = pager.highlight_matches(painted, "needle")
818 let assert True = string.contains(hi, "\u{001b}[32m")
819 let assert True = string.contains(hi, "\u{001b}[30;103m")
820 let assert "needle here" = color.strip_ansi(hi)
821 let assert True = string.contains(hi, "\u{001b}[39;49m")
822 Nil
823}
824
825pub fn pager_highlight_match_spanning_sgr_test() {
826 // Match crosses a mid-string color change: black-on-yellow re-opens after SGR.
827 let line = "ab\u{001b}[31mcd\u{001b}[0mef"
828 let hi = pager.highlight_matches(line, "bcde")
829 let assert True = string.contains(hi, "\u{001b}[30;103m")
830 let assert "abcdef" = color.strip_ansi(hi)
831 // After the red open, accent should be re-applied inside the match.
832 let assert True = string.contains(hi, "\u{001b}[31m\u{001b}[30;103m")
833 Nil
834}
835
836pub fn less_short_output_passthrough_test() {
837 // Non-TTY test runner: needs_paging is false → less returns the text.
838 let env = env.new()
839 let assert eval.Continue(_, String(out)) =
840 eval.eval_source(env, "echo hello | less")
841 let assert True = string.contains(out, "hello")
842 let assert eval.Continue(_, String(which_out)) =
843 eval.eval_source(env, "which less")
844 let assert "builtin: less" = which_out
845 Nil
846}
847
848pub fn less_preserves_ansi_in_string_test() {
849 let env = env.new()
850 // Pre-colored multi-line text must survive less (no re-paint / strip).
851 let colored = "\u{001b}[31mred\u{001b}[0m\n\u{001b}[32mgreen\u{001b}[0m"
852 let assert eval.Continue(_, String(out)) =
853 eval.eval_source(env, "echo \"" <> escape_for_source(colored) <> "\" | less")
854 let assert True = string.contains(out, "\u{001b}[31m")
855 let assert True = string.contains(out, "red")
856 let assert True = string.contains(out, "\u{001b}[32m")
857 let assert True = string.contains(out, "green")
858 Nil
859}
860
861pub fn less_no_input_errors_test() {
862 let env = env.new()
863 let assert eval.Continue(env2, value.Fail(msg)) = eval.eval_source(env, "less")
864 let assert True = string.contains(msg, "no input")
865 let assert 1 = env2.last_exit
866 Nil
867}
868
869pub fn less_help_test() {
870 let env = env.new()
871 let assert eval.Continue(_, String(help_out)) =
872 eval.eval_source(env, "help less")
873 let assert True = string.contains(help_out, "ANSI")
874 let assert True = string.contains(help_out, "q")
875 let assert True = string.contains(help_out, "/pattern")
876 let assert True = string.contains(help_out, "n / N")
877 Nil
878}
879
880pub fn git_log_pipeline_emits_ansi_and_decorate_test() {
881 // Capture uses a throwaway PTY when color is wanted, so git colorizes and
882 // keeps ref decorations without GIT_CONFIG_* hacks. FORCE_COLOR makes
883 // want_child_color true in non-TTY test runners.
884 let prev = sys.getenv("FORCE_COLOR")
885 let assert Ok(_) = sys.setenv("FORCE_COLOR", "1")
886 let env = env.new()
887 // Full log (not --oneline): decorations appear on the commit line.
888 let result = eval.eval_source(env, "git log -1 | identity")
889 case prev {
890 Ok(v) -> {
891 let assert Ok(_) = sys.setenv("FORCE_COLOR", v)
892 Nil
893 }
894 Error(Nil) -> {
895 let assert Ok(_) = sys.setenv("FORCE_COLOR", "")
896 Nil
897 }
898 }
899 let assert eval.Continue(_, String(out)) = result
900 // Real git colors, not gleshell string-green on plain text.
901 let assert True = string.contains(out, "\u{001b}[")
902 // TTY-style decorate: ref names like HEAD / main.
903 let assert True = string.contains(out, "HEAD") || string.contains(out, "main")
904 Nil
905}
906
907pub fn jj_log_pipeline_emits_ansi_test() {
908 // jj ignores FORCE_COLOR; PTY capture is what keeps colors for `jj log | less`.
909 let prev = sys.getenv("FORCE_COLOR")
910 let assert Ok(_) = sys.setenv("FORCE_COLOR", "1")
911 let env = env.new()
912 let result = eval.eval_source(env, "jj log -n 1 | identity")
913 case prev {
914 Ok(v) -> {
915 let assert Ok(_) = sys.setenv("FORCE_COLOR", v)
916 Nil
917 }
918 Error(Nil) -> {
919 let assert Ok(_) = sys.setenv("FORCE_COLOR", "")
920 Nil
921 }
922 }
923 let assert eval.Continue(_, String(out)) = result
924 let assert True = string.contains(out, "\u{001b}[")
925 Nil
926}
927
928/// Embed a string in double-quoted source: escape `\` and `"`.
929fn escape_for_source(s: String) -> String {
930 s
931 |> string.replace("\\", "\\\\")
932 |> string.replace("\"", "\\\"")
933}
934
935// --- input syntax highlighting ---
936
937pub fn highlight_plain_when_off_test() {
938 let src = "ls | where type == file"
939 let assert True = highlight.highlight(False, src) == src
940 Nil
941}
942
943pub fn highlight_pipeline_has_shapes_test() {
944 let text = highlight.highlight(True, "ls | first 3")
945 // bold cyan internalcall, bold purple pipe, bold purple int
946 let assert True = string_contains(text, "\u{001b}[1;36m")
947 let assert True = string_contains(text, "\u{001b}[1;35m")
948 let assert True = string_contains(text, "ls")
949 let assert True = string_contains(text, "first")
950 let assert True = string_contains(text, "3")
951 // visible text unchanged
952 let assert 12 = color.visible_length(text)
953 Nil
954}
955
956pub fn highlight_string_and_flag_test() {
957 let text = highlight.highlight(True, "echo \"hi\" --raw")
958 let assert True = string_contains(text, "\u{001b}[32m")
959 let assert True = string_contains(text, "\u{001b}[1;34m")
960 let assert True = string_contains(text, "hi")
961 let assert True = string_contains(text, "--raw")
962 Nil
963}
964
965pub fn highlight_variable_and_let_test() {
966 let text = highlight.highlight(True, "let x = $in")
967 let assert True = string_contains(text, "\u{001b}[1;36m")
968 let assert True = string_contains(text, "\u{001b}[35m")
969 let assert True = string_contains(text, "let")
970 let assert True = string_contains(text, "$in")
971 Nil
972}
973
974pub fn highlight_incomplete_string_test() {
975 // Unterminated string while typing should not crash
976 let text = highlight.highlight(True, "echo \"hel")
977 let assert True = string_contains(text, "hel")
978 let assert True = string_contains(text, "\u{001b}[32m")
979 Nil
980}
981
982pub fn highlight_to_command_test() {
983 // `to` is a builtin; `json` is a subcommand arg
984 let text = highlight.highlight(True, "range 3 | to json")
985 let assert True = string_contains(text, "to")
986 let assert True = string_contains(text, "json")
987 let assert True = string_contains(text, "\u{001b}[1;36m")
988 Nil
989}
990
991pub fn highlight_path_arg_not_garbage_test() {
992 // Dotfile paths must not use shape_garbage (white-on-red)
993 let text = highlight.highlight(True, "ls .jj ./src /tmp ~/code")
994 let assert True = string_contains(text, ".jj")
995 let assert False = string_contains(text, "\u{001b}[1;37;41m")
996 // args use shape_externalarg (bold green)
997 let assert True = string_contains(text, "\u{001b}[1;32m")
998 Nil
999}
1000
1001fn string_contains(haystack: String, needle: String) -> Bool {
1002 case string.split(haystack, needle) {
1003 [_] -> False
1004 _ -> True
1005 }
1006}
1007
1008// --- file syntax (cat highlighters) ---
1009
1010pub fn syntax_language_from_path_test() {
1011 let assert syntax.Json = syntax.language_from_path("data/foo.json")
1012 let assert syntax.Gleam = syntax.language_from_path("src/main.gleam")
1013 let assert syntax.Toml = syntax.language_from_path("gleam.toml")
1014 let assert syntax.Markdown = syntax.language_from_path("README.md")
1015 let assert syntax.Plain = syntax.language_from_path("notes.txt")
1016 let assert syntax.Plain = syntax.language_from_path("Makefile")
1017 Nil
1018}
1019
1020pub fn syntax_detect_sniff_json_test() {
1021 let assert syntax.Json = syntax.detect("data", "{\"a\": 1}")
1022 let assert syntax.Markdown = syntax.detect("notes", "# Title\n\nbody")
1023 let assert syntax.Plain = syntax.detect("x", "just words")
1024 // Extension wins over sniff
1025 let assert syntax.Toml = syntax.detect("x.toml", "{\"a\": 1}")
1026 Nil
1027}
1028
1029pub fn syntax_is_binary_test() {
1030 let assert False = syntax.is_binary("hello\nworld\t!")
1031 let assert True = syntax.is_binary("a\u{0000}b")
1032 Nil
1033}
1034
1035pub fn syntax_paint_json_test() {
1036 let src = "{\"n\": 1, \"msg\": \"hi\", \"ok\": true}"
1037 let assert True = syntax.paint(False, syntax.Json, src) == src
1038 let painted = syntax.paint(True, syntax.Json, src)
1039 // Truecolor roles: keys (sky), string values (green), numbers, bools
1040 let assert True = string_contains(painted, "38;2")
1041 let assert True = string_contains(painted, "true")
1042 let assert True = string_contains(painted, "137;220;235")
1043 let assert True = string_contains(painted, "166;227;161")
1044 let assert True = string_contains(painted, "250;179;135")
1045 let assert True = color.strip_ansi(painted) == src
1046 Nil
1047}
1048
1049pub fn syntax_paint_gleam_test() {
1050 let src = "pub fn main() {\n // hi\n 42\n}"
1051 let painted = syntax.paint(True, syntax.Gleam, src)
1052 // keyword mauve, fn name blue, comment italic, number peach
1053 let assert True = string_contains(painted, "203;166;247")
1054 let assert True = string_contains(painted, "137;180;250")
1055 let assert True = string_contains(painted, "108;112;134")
1056 let assert True = string_contains(painted, "250;179;135")
1057 let assert True = string_contains(painted, "pub")
1058 let assert True = string_contains(painted, "main")
1059 let assert True = color.strip_ansi(painted) == src
1060 Nil
1061}
1062
1063pub fn syntax_paint_toml_test() {
1064 let src = "name = \"gleshell\"\n# comment\nenabled = true"
1065 let painted = syntax.paint(True, syntax.Toml, src)
1066 let assert True = string_contains(painted, "38;2")
1067 let assert True = string_contains(painted, "166;227;161")
1068 let assert True = string_contains(painted, "108;112;134")
1069 let assert True = color.strip_ansi(painted) == src
1070 Nil
1071}
1072
1073pub fn syntax_paint_markdown_test() {
1074 let src = "# Title\n\nUse `code` and **bold**.\n"
1075 let painted = syntax.paint(True, syntax.Markdown, src)
1076 let assert True = string_contains(painted, "Title")
1077 let assert True = string_contains(painted, "code")
1078 // H1 lavender + bold peach for **bold**
1079 let assert True = string_contains(painted, "180;190;254")
1080 let assert True = string_contains(painted, "250;179;135")
1081 let assert True = color.visible_length(painted)
1082 >= color.visible_length(src) - 1
1083 Nil
1084}
1085
1086pub fn syntax_frame_gutter_test() {
1087 let body = "alpha\nbeta"
1088 let framed = syntax.frame("src/demo.gleam", syntax.Gleam, body)
1089 let stripped = color.strip_ansi(framed)
1090 // Header carries basename + language badge
1091 let assert True = string_contains(stripped, "demo.gleam")
1092 let assert True = string_contains(stripped, "gleam")
1093 // Line numbers + pipe gutter
1094 let assert True = string_contains(stripped, "1")
1095 let assert True = string_contains(stripped, "2")
1096 let assert True = string_contains(stripped, "│")
1097 let assert True = string_contains(stripped, "alpha")
1098 let assert True = string_contains(stripped, "beta")
1099 Nil
1100}
1101
1102pub fn cat_raw_and_language_test() {
1103 let env = env.new()
1104 // Write a temp json file under the project (simplifile needs a real path).
1105 let path = "build/cat_syntax_test.json"
1106 let body = "{\"x\": 1}"
1107 let assert Ok(Nil) = simplifile.write(to: path, contents: body)
1108
1109 // --raw must return plain content even when colors would be on.
1110 let assert eval.Continue(_, String(raw_out)) =
1111 eval.eval_source(env, "cat " <> path <> " --raw")
1112 let assert True = raw_out == body
1113
1114 // --language plain: no syntax colors (may still frame with line numbers on TTY).
1115 let assert eval.Continue(_, String(plain_out)) =
1116 eval.eval_source(env, "cat " <> path <> " --language plain")
1117 let plain_stripped = color.strip_ansi(plain_out)
1118 let assert True =
1119 plain_out == body
1120 || plain_stripped == body
1121 || string_contains(plain_stripped, body)
1122
1123 // Forced gleam: visible body still present (gutter/header OK).
1124 let assert eval.Continue(_, String(gleam_out)) =
1125 eval.eval_source(env, "cat " <> path <> " --language gleam")
1126 let gleam_stripped = color.strip_ansi(gleam_out)
1127 let assert True =
1128 gleam_stripped == body || string_contains(gleam_stripped, body)
1129
1130 // Unknown language errors.
1131 let assert eval.Continue(_, value.Fail(msg)) =
1132 eval.eval_source(env, "cat " <> path <> " --language cobol")
1133 let assert True = string.contains(msg, "unknown language")
1134
1135 let _ = simplifile.delete(path)
1136 Nil
1137}
1138
1139// --- tab completion ---
1140
1141pub fn complete_command_builtin_test() {
1142 // Start of line: complete builtins
1143 let #(matches, kind) = sys.complete_word("", "ech")
1144 let assert True = kind == "command"
1145 let assert True = list_contains(matches, "echo")
1146 // After pipeline separator
1147 let #(matches2, kind2) = sys.complete_word("ls | ", "wher")
1148 let assert True = kind2 == "command"
1149 let assert True = list_contains(matches2, "where")
1150 // `to` / `from` are single-word commands (subcommand is a normal arg)
1151 let #(matches3, kind3) = sys.complete_word("", "to")
1152 let assert True = kind3 == "command"
1153 let assert True = list_contains(matches3, "to")
1154 let #(matches_from, kind_from) = sys.complete_word("", "fro")
1155 let assert True = kind_from == "command"
1156 let assert True = list_contains(matches_from, "from")
1157 // Keyword
1158 let #(matches4, kind4) = sys.complete_word("", "le")
1159 let assert True = kind4 == "command"
1160 let assert True = list_contains(matches4, "let")
1161 Nil
1162}
1163
1164pub fn complete_command_after_assign_test() {
1165 let #(matches, kind) = sys.complete_word("let x = ", "ran")
1166 let assert True = kind == "command"
1167 let assert True = list_contains(matches, "range")
1168 Nil
1169}
1170
1171pub fn complete_path_for_args_test() {
1172 // After a command name, Tab completes files not commands
1173 let #(_matches, kind) = sys.complete_word("echo ", "ech")
1174 let assert True = kind == "path"
1175 Nil
1176}
1177
1178pub fn complete_path_like_command_test() {
1179 // Path-shaped command words stay on filename completion
1180 let #(_matches, kind) = sys.complete_word("", "./ec")
1181 let assert True = kind == "path"
1182 let #(_matches2, kind2) = sys.complete_word("", "/us")
1183 let assert True = kind2 == "path"
1184 Nil
1185}
1186
1187pub fn complete_path_executable_test() {
1188 // PATH + builtins for a non-empty prefix
1189 let #(matches, kind) = sys.complete_word("", "s")
1190 let assert True = kind == "command"
1191 // At least builtins starting with s (select, skip, sort-by, …)
1192 let assert True = list_contains(matches, "select")
1193 Nil
1194}
1195
1196// --- history ghost-text hints (Nu/fish style) ---
1197
1198pub fn history_hint_newest_prefix_test() {
1199 // Newest-first: first entry that has the buffer as a proper prefix wins.
1200 let hist = ["ls | where type == file", "ls | first 3", "echo hi"]
1201 let assert " | where type == file" = sys.history_hint(hist, "ls")
1202 let assert " | first 3" = sys.history_hint(["ls | first 3", "ls | where x"], "ls")
1203 let assert " type == file" = sys.history_hint(hist, "ls | where")
1204 let assert " hi" = sys.history_hint(hist, "echo")
1205 Nil
1206}
1207
1208pub fn history_hint_no_match_test() {
1209 let hist = ["ls", "echo hi"]
1210 let assert "" = sys.history_hint(hist, "cd")
1211 // Exact match only — no suffix
1212 let assert "" = sys.history_hint(hist, "ls")
1213 // Empty buffer never suggests
1214 let assert "" = sys.history_hint(hist, "")
1215 Nil
1216}
1217
1218fn list_contains(items: List(String), needle: String) -> Bool {
1219 case items {
1220 [] -> False
1221 [x, ..] if x == needle -> True
1222 [_, ..rest] -> list_contains(rest, needle)
1223 }
1224}