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