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