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