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