A structured-data shell in Gleam, inspired by Nushell
0

Configure Feed

Select the types of activity you want to include in your feed.

gleshell / test / gleshell_test.gleam
56 kB 1648 lines
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}