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
20 kB 639 lines
1import gleam/list 2import gleam/string 3import gleeunit 4import gleshell/builtins 5import gleshell/color 6import gleshell/display 7import gleshell/env 8import gleshell/eval 9import gleshell/highlight 10import gleshell/lexer 11import gleshell/parser 12import gleshell/sys 13import gleshell/value.{Bool, Int, List, Nothing, Record, String, Table} 14 15pub fn main() -> Nil { 16 gleeunit.main() 17} 18 19// --- lexer --- 20 21pub fn lexer_pipeline_test() { 22 let assert Ok(tokens) = lexer.tokenize("ls | where type == file") 23 let assert [lexer.Ident("ls"), lexer.Pipe, lexer.Ident("where"), ..] = tokens 24 Nil 25} 26 27pub fn lexer_string_and_number_test() { 28 let assert Ok(tokens) = lexer.tokenize("echo \"hi\" 42 3.14 true") 29 let assert [ 30 lexer.Ident("echo"), 31 lexer.StringLit("hi"), 32 lexer.IntLit(42), 33 lexer.FloatLit(3.14), 34 lexer.BoolLit(True), 35 lexer.Eof, 36 ] = tokens 37 Nil 38} 39 40pub fn lexer_path_idents_test() { 41 // Dotfiles, relative/absolute paths, home — must be bare words for `ls .jj` etc. 42 let assert Ok(tokens) = lexer.tokenize("ls .jj ./src ../foo /tmp ~ ~/code") 43 let assert [ 44 lexer.Ident("ls"), 45 lexer.Ident(".jj"), 46 lexer.Ident("./src"), 47 lexer.Ident("../foo"), 48 lexer.Ident("/tmp"), 49 lexer.Ident("~"), 50 lexer.Ident("~/code"), 51 lexer.Eof, 52 ] = tokens 53 Nil 54} 55 56pub fn parse_ls_dotfile_test() { 57 let assert Ok(parser.Expr(parser.Pipeline([ 58 parser.Command("ls", args, False), 59 ]))) = parser.parse("ls .jj") 60 let assert [parser.ValueArg(parser.Lit(String(".jj")))] = args 61 Nil 62} 63 64// --- parser --- 65 66pub fn parse_pipeline_test() { 67 let assert Ok(parser.Expr(parser.Pipeline(cmds))) = 68 parser.parse("ls | first 3") 69 let assert [ 70 parser.Command("ls", [], False), 71 parser.Command("first", args, False), 72 ] = cmds 73 let assert [parser.ValueArg(parser.Lit(Int(3)))] = args 74 Nil 75} 76 77pub fn parse_let_test() { 78 let assert Ok(parser.Let( 79 "x", 80 parser.Pipeline([parser.Command("echo", args, False)]), 81 )) = parser.parse("let x = echo 1") 82 let assert [parser.ValueArg(parser.Lit(Int(1)))] = args 83 Nil 84} 85 86pub fn parse_where_ops_test() { 87 let assert Ok(parser.Expr(parser.Pipeline([ 88 parser.Command("where", args, False), 89 ]))) = parser.parse("where type == file") 90 let assert [ 91 parser.ValueArg(parser.Lit(String("type"))), 92 parser.ValueArg(parser.Lit(String("=="))), 93 parser.ValueArg(parser.Lit(String("file"))), 94 ] = args 95 Nil 96} 97 98pub fn parse_list_and_record_test() { 99 let assert Ok(parser.Expr(parser.Pipeline([ 100 parser.Command("echo", args, False), 101 ]))) = parser.parse("echo [1 2] {a: true}") 102 let assert [ 103 parser.ValueArg(parser.ListExpr([parser.Lit(Int(1)), parser.Lit(Int(2))])), 104 parser.ValueArg(parser.RecordExpr([#("a", parser.Lit(Bool(True)))])), 105 ] = args 106 Nil 107} 108 109// --- value helpers --- 110 111pub fn table_from_records_test() { 112 let rows = [ 113 Record([#("name", String("a")), #("n", Int(1))]), 114 Record([#("name", String("b")), #("n", Int(2))]), 115 ] 116 let assert Table( 117 ["name", "n"], 118 [[String("a"), Int(1)], [String("b"), Int(2)]], 119 ) = value.table_from_records(rows) 120 Nil 121} 122 123// --- eval --- 124 125pub fn eval_echo_and_range_test() { 126 let env = env.new() 127 let assert eval.Continue(_, List([Int(0), Int(1), Int(2)])) = 128 eval.eval_source(env, "range 3") 129 let assert eval.Continue(_, String("hello")) = 130 eval.eval_source(env, "echo hello") 131 Nil 132} 133 134pub fn eval_pipeline_reverse_first_test() { 135 let env = env.new() 136 let assert eval.Continue(_, Int(2)) = 137 eval.eval_source(env, "range 3 | reverse | first") 138 Nil 139} 140 141/// Pipeline input must become the external's stdin (`cat f | less`, `echo hi | wc`). 142/// Use `let` so the last stage is capture mode even on a TTY (bare expressions 143/// may inherit the terminal and return an empty string value). 144pub fn eval_pipeline_stdin_to_external_test() { 145 let env = env.new() 146 // `echo` is a builtin; `wc` is external. Count bytes of "hello" (no trailing NL). 147 let assert eval.Continue(_, String(out)) = 148 eval.eval_source(env, "let n = echo hello | ^wc -c") 149 let assert True = string.contains(out, "5") 150 // Tight `cmd|cmd` form (no spaces around pipe) 151 let assert eval.Continue(_, String(out2)) = 152 eval.eval_source(env, "let m = echo ab|^wc -c") 153 let assert True = string.contains(out2, "2") 154 Nil 155} 156 157pub fn eval_let_and_var_test() { 158 let env = env.new() 159 let assert eval.Continue(env2, Int(7)) = 160 eval.eval_source(env, "let n = echo 7") 161 let assert eval.Continue(_, Int(7)) = eval.eval_source(env2, "echo $n") 162 Nil 163} 164 165pub fn eval_env_var_get_test() { 166 // `$env.HOME` and bare `$env.HOME` as pipeline source 167 let env = env.new() 168 let assert Ok(home) = sys.getenv("HOME") 169 let assert eval.Continue(_, String(got)) = 170 eval.eval_source(env, "echo $env.HOME") 171 let assert True = got == home 172 let assert eval.Continue(_, String(got2)) = eval.eval_source(env, "$env.HOME") 173 let assert True = got2 == home 174 Nil 175} 176 177pub fn eval_env_record_test() { 178 let env = env.new() 179 let assert eval.Continue(_, Record(fields)) = eval.eval_source(env, "$env") 180 let assert True = list_has_string_field(fields, "HOME") 181 let assert True = list_has_string_field(fields, "PATH") 182 // `$env | get HOME` 183 let assert eval.Continue(_, String(home)) = 184 eval.eval_source(env, "$env | get HOME") 185 let assert True = string.length(home) > 0 186 Nil 187} 188 189pub fn eval_env_assign_test() { 190 let env = env.new() 191 let assert eval.Continue(env2, String("gleshell-test-val")) = 192 eval.eval_source(env, "$env.GLESHELL_TEST_VAR = gleshell-test-val") 193 let assert eval.Continue(_, String("gleshell-test-val")) = 194 eval.eval_source(env2, "$env.GLESHELL_TEST_VAR") 195 let assert Ok("gleshell-test-val") = sys.getenv("GLESHELL_TEST_VAR") 196 Nil 197} 198 199pub fn parse_env_assign_test() { 200 let assert Ok(parser.EnvAssign("FOO", parser.Pipeline([cmd]))) = 201 parser.parse("$env.FOO = hello") 202 let assert parser.Command( 203 "__value__", 204 [parser.ValueArg(parser.Lit(String("hello")))], 205 False, 206 ) = cmd 207 Nil 208} 209 210fn list_has_string_field( 211 fields: List(#(String, value.Value)), 212 key: String, 213) -> Bool { 214 case fields { 215 [] -> False 216 [#(k, String(_)), ..] if k == key -> True 217 [_, ..rest] -> list_has_string_field(rest, key) 218 } 219} 220 221pub fn eval_where_select_test() { 222 let env = env.new() 223 // build table via records in a list, convert with table 224 let assert eval.Continue(_, result) = 225 eval.eval_source( 226 env, 227 "echo [{name: a, n: 1} {name: b, n: 2} {name: c, n: 3}] | table | where n > 1 | select name", 228 ) 229 let assert Table(["name"], rows) = result 230 let assert [[String("b")], [String("c")]] = rows 231 Nil 232} 233 234pub fn help_covers_all_builtins_test() { 235 // Every registered builtin must have a dedicated help_text entry. 236 let assert [] = builtins.missing_help() 237 238 let env = env.new() 239 // which finds builtins; help must too (not "unknown command") 240 let assert eval.Continue(_, String(which_out)) = 241 eval.eval_source(env, "which table") 242 let assert "builtin: table" = which_out 243 let assert eval.Continue(_, String(help_out)) = 244 eval.eval_source(env, "help table") 245 let assert True = string.contains(help_out, "table") 246 let assert True = string.contains(help_out, "coerce") 247 248 // Parent commands with subcommands: `help to` / `help from` must resolve. 249 let assert eval.Continue(_, String(to_help)) = 250 eval.eval_source(env, "help to") 251 let assert True = string.contains(to_help, "json") 252 let assert eval.Continue(_, String(from_help)) = 253 eval.eval_source(env, "help from") 254 let assert True = string.contains(from_help, "json") 255 256 // Bare help lists every command with its one-line description. 257 let assert eval.Continue(_, String(all)) = eval.eval_source(env, "help") 258 list.each(builtins.names(), fn(name) { 259 let assert True = string.contains(all, name) 260 }) 261 262 let assert eval.Continue(env2, value.Fail(msg)) = 263 eval.eval_source(env, "help not-a-real-cmd") 264 let assert True = string.contains(msg, "unknown command") 265 let assert 1 = env2.last_exit 266 Nil 267} 268 269pub fn eval_from_json_test() { 270 let env = env.new() 271 let assert eval.Continue(_, Record(fields)) = 272 eval.eval_source(env, "echo \"{\\\"x\\\": 1}\" | from json") 273 let assert True = list_has_field(fields, "x", Int(1)) 274 Nil 275} 276 277pub fn eval_to_json_pretty_test() { 278 let env = env.new() 279 // `to` command + `json` subcommand — pretty by default 280 let assert eval.Continue(_, String(pretty)) = 281 eval.eval_source(env, "echo [1 2 3] | to json") 282 let assert True = string.contains(pretty, "\n") 283 let assert True = string.contains(pretty, "1") 284 // `--raw` matches Nu: compact, no trailing newline 285 let assert eval.Continue(_, String(raw)) = 286 eval.eval_source(env, "echo [1 2 3] | to json --raw") 287 let assert "[1,2,3]" = raw 288 // Missing / unknown subcommand 289 let assert eval.Continue(env2, value.Fail(msg)) = 290 eval.eval_source(env, "echo 1 | to") 291 let assert True = string.contains(msg, "subcommand") 292 let assert 1 = env2.last_exit 293 let assert eval.Continue(_, value.Fail(msg2)) = 294 eval.eval_source(env, "echo 1 | to yaml") 295 let assert True = string.contains(msg2, "unknown subcommand") 296 Nil 297} 298 299pub fn eval_to_json_record_test() { 300 let env = env.new() 301 let assert eval.Continue(_, String(raw)) = 302 eval.eval_source(env, "echo {a: 1, b: true} | to json -r") 303 let assert True = string.contains(raw, "\"a\":1") 304 let assert True = string.contains(raw, "\"b\":true") 305 Nil 306} 307 308fn list_has_field( 309 fields: List(#(String, value.Value)), 310 key: String, 311 expected: value.Value, 312) -> Bool { 313 case fields { 314 [] -> False 315 [#(k, v), ..rest] -> 316 case k == key { 317 True -> value.equals(v, expected) 318 False -> list_has_field(rest, key, expected) 319 } 320 } 321} 322 323pub fn eval_length_test() { 324 let env = env.new() 325 let assert eval.Continue(_, Int(4)) = 326 eval.eval_source(env, "range 4 | length") 327 Nil 328} 329 330pub fn eval_which_builtin_test() { 331 let env = env.new() 332 let assert eval.Continue(_, String("builtin: ls")) = 333 eval.eval_source(env, "which ls") 334 Nil 335} 336 337pub fn eval_find_list_test() { 338 let env = env.new() 339 // Substring match (OR across terms) 340 let assert eval.Continue(_, List(items)) = 341 eval.eval_source(env, "echo [moe larry curly] | find l") 342 let assert [String("larry"), String("curly")] = items 343 // Multiple terms 344 let assert eval.Continue(_, List(items2)) = 345 eval.eval_source(env, "echo [a.toml b.md c.rs] | find toml md") 346 let assert [String("a.toml"), String("b.md")] = items2 347 // Exact number match 348 let assert eval.Continue(_, List([Int(5)])) = 349 eval.eval_source(env, "echo [1 5 3 4 35] | find 5") 350 Nil 351} 352 353pub fn eval_find_ignore_case_invert_test() { 354 let env = env.new() 355 let assert eval.Continue(_, List(items)) = 356 eval.eval_source(env, "echo [Hello world HELLO] | find hello -i") 357 let assert [String("Hello"), String("HELLO")] = items 358 // `-i term` may attach term to the flag; still works 359 let assert eval.Continue(_, List(items2)) = 360 eval.eval_source(env, "echo [Hello world HELLO] | find -i hello") 361 let assert [String("Hello"), String("HELLO")] = items2 362 let assert eval.Continue(_, List([String("cd")])) = 363 eval.eval_source(env, "echo [ab cd] | find --invert a") 364 Nil 365} 366 367pub fn eval_find_table_and_string_test() { 368 let env = env.new() 369 let assert eval.Continue(_, Table(["name", "type"], rows)) = 370 eval.eval_source( 371 env, 372 "echo [{name: a, type: file} {name: b, type: dir}] | table | find file", 373 ) 374 let assert [[String("a"), String("file")]] = rows 375 // Single-line string: return the string if match, else nothing 376 let assert eval.Continue(_, String("Cargo.toml")) = 377 eval.eval_source(env, "echo Cargo.toml | find Cargo") 378 let assert eval.Continue(_, Nothing) = 379 eval.eval_source(env, "echo Cargo.toml | find zz") 380 // Multi-line string → list of matching lines 381 let assert eval.Continue(_, List(lines)) = 382 eval.eval_source(env, "echo \"hi\nbye\nhi there\" | find hi") 383 let assert [String("hi"), String("hi there")] = lines 384 Nil 385} 386 387pub fn eval_find_regex_test() { 388 let env = env.new() 389 let assert eval.Continue(_, List(items)) = 390 eval.eval_source(env, "echo [abc odb arc abf] | find --regex \"b.\"") 391 let assert [String("abc"), String("abf")] = items 392 // Regex + terms is rejected (Nu-compatible) 393 let assert eval.Continue(env2, value.Fail(msg)) = 394 eval.eval_source(env, "echo [abc] | find --regex \"b.\" x") 395 let assert True = string.contains(msg, "regex") 396 let assert 1 = env2.last_exit 397 Nil 398} 399 400pub fn eval_which_all_test() { 401 let env = env.new() 402 // `-a` includes the builtin first, then any PATH copies of `ls`. 403 let assert eval.Continue(_, result) = eval.eval_source(env, "which -a ls") 404 case result { 405 String("builtin: ls") -> Nil 406 List([String("builtin: ls"), ..]) -> Nil 407 other -> { 408 let _ = other 409 panic as "which -a ls should start with builtin: ls" 410 } 411 } 412} 413 414pub fn eval_which_external_test() { 415 let env = env.new() 416 // `sh` is not a gleshell builtin; should resolve via PATH. 417 let assert eval.Continue(_, String(path)) = eval.eval_source(env, "which sh") 418 let assert True = string.contains(path, "sh") 419 Nil 420} 421 422pub fn value_nothing_falsey_test() { 423 let assert False = value.is_truthy(Nothing) 424 let assert True = value.is_truthy(Int(1)) 425 Nil 426} 427 428// --- display / color --- 429 430pub fn display_plain_no_ansi_test() { 431 let text = display.render_with(False, Bool(True)) 432 let assert "true" = text 433 let assert False = string_contains(text, "\u{001b}") 434 Nil 435} 436 437pub fn display_colored_has_ansi_test() { 438 let text = display.render_with(True, Bool(True)) 439 let assert True = string_contains(text, "\u{001b}") 440 let assert True = string_contains(text, "true") 441 Nil 442} 443 444pub fn display_preserves_external_ansi_test() { 445 // Pre-colored tool output (jj, git, …) must not be re-wrapped in string green. 446 let app = "\u{001b}[1;31merror\u{001b}[0m: boom" 447 let text = display.render_with(True, String(app)) 448 let assert True = text == app 449 Nil 450} 451 452pub fn display_multiline_string_not_recolored_test() { 453 let multi = "line1\nline2" 454 let text = display.render_with(True, String(multi)) 455 let assert True = text == multi 456 Nil 457} 458 459pub fn display_table_headers_colored_test() { 460 let text = 461 display.render_with( 462 True, 463 Table(["name", "type"], [[String("src"), String("dir")]]), 464 ) 465 // bold green header + bright-blue dir name 466 let assert True = string_contains(text, "\u{001b}[1;32m") 467 let assert True = string_contains(text, "\u{001b}[94m") 468 let assert True = string_contains(text, "src") 469 Nil 470} 471 472pub fn format_filesize_units_test() { 473 let assert "0 B" = display.format_filesize(0) 474 let assert "512 B" = display.format_filesize(512) 475 let assert "1023 B" = display.format_filesize(1023) 476 let assert "1 KB" = display.format_filesize(1024) 477 let assert "1.5 KB" = display.format_filesize(1536) 478 let assert "1 MB" = display.format_filesize(1_048_576) 479 let assert "1.5 MB" = display.format_filesize(1_048_576 + 524_288) 480 let assert "1 GB" = display.format_filesize(1_073_741_824) 481 Nil 482} 483 484pub fn display_size_column_humanized_test() { 485 // Data stays as raw bytes (Int); only display shows KB/MB. 486 let text = 487 display.render_with( 488 False, 489 Table(["name", "size"], [[String("a"), Int(2048)]]), 490 ) 491 let assert True = string_contains(text, "2 KB") 492 let assert False = string_contains(text, "2048") 493 Nil 494} 495 496pub fn color_visible_length_strips_ansi_test() { 497 let painted = color.paint(True, "\u{001b}[32m", "hi") 498 let assert 2 = color.visible_length(painted) 499 let assert 2 = color.visible_length("hi") 500 Nil 501} 502 503// --- input syntax highlighting --- 504 505pub fn highlight_plain_when_off_test() { 506 let src = "ls | where type == file" 507 let assert True = highlight.highlight(False, src) == src 508 Nil 509} 510 511pub fn highlight_pipeline_has_shapes_test() { 512 let text = highlight.highlight(True, "ls | first 3") 513 // bold cyan internalcall, bold purple pipe, bold purple int 514 let assert True = string_contains(text, "\u{001b}[1;36m") 515 let assert True = string_contains(text, "\u{001b}[1;35m") 516 let assert True = string_contains(text, "ls") 517 let assert True = string_contains(text, "first") 518 let assert True = string_contains(text, "3") 519 // visible text unchanged 520 let assert 12 = color.visible_length(text) 521 Nil 522} 523 524pub fn highlight_string_and_flag_test() { 525 let text = highlight.highlight(True, "echo \"hi\" --raw") 526 let assert True = string_contains(text, "\u{001b}[32m") 527 let assert True = string_contains(text, "\u{001b}[1;34m") 528 let assert True = string_contains(text, "hi") 529 let assert True = string_contains(text, "--raw") 530 Nil 531} 532 533pub fn highlight_variable_and_let_test() { 534 let text = highlight.highlight(True, "let x = $in") 535 let assert True = string_contains(text, "\u{001b}[1;36m") 536 let assert True = string_contains(text, "\u{001b}[35m") 537 let assert True = string_contains(text, "let") 538 let assert True = string_contains(text, "$in") 539 Nil 540} 541 542pub fn highlight_incomplete_string_test() { 543 // Unterminated string while typing should not crash 544 let text = highlight.highlight(True, "echo \"hel") 545 let assert True = string_contains(text, "hel") 546 let assert True = string_contains(text, "\u{001b}[32m") 547 Nil 548} 549 550pub fn highlight_to_command_test() { 551 // `to` is a builtin; `json` is a subcommand arg 552 let text = highlight.highlight(True, "range 3 | to json") 553 let assert True = string_contains(text, "to") 554 let assert True = string_contains(text, "json") 555 let assert True = string_contains(text, "\u{001b}[1;36m") 556 Nil 557} 558 559pub fn highlight_path_arg_not_garbage_test() { 560 // Dotfile paths must not use shape_garbage (white-on-red) 561 let text = highlight.highlight(True, "ls .jj ./src /tmp ~/code") 562 let assert True = string_contains(text, ".jj") 563 let assert False = string_contains(text, "\u{001b}[1;37;41m") 564 // args use shape_externalarg (bold green) 565 let assert True = string_contains(text, "\u{001b}[1;32m") 566 Nil 567} 568 569fn string_contains(haystack: String, needle: String) -> Bool { 570 case string.split(haystack, needle) { 571 [_] -> False 572 _ -> True 573 } 574} 575 576// --- tab completion --- 577 578pub fn complete_command_builtin_test() { 579 // Start of line: complete builtins 580 let #(matches, kind) = sys.complete_word("", "ech") 581 let assert True = kind == "command" 582 let assert True = list_contains(matches, "echo") 583 // After pipeline separator 584 let #(matches2, kind2) = sys.complete_word("ls | ", "wher") 585 let assert True = kind2 == "command" 586 let assert True = list_contains(matches2, "where") 587 // `to` / `from` are single-word commands (subcommand is a normal arg) 588 let #(matches3, kind3) = sys.complete_word("", "to") 589 let assert True = kind3 == "command" 590 let assert True = list_contains(matches3, "to") 591 let #(matches_from, kind_from) = sys.complete_word("", "fro") 592 let assert True = kind_from == "command" 593 let assert True = list_contains(matches_from, "from") 594 // Keyword 595 let #(matches4, kind4) = sys.complete_word("", "le") 596 let assert True = kind4 == "command" 597 let assert True = list_contains(matches4, "let") 598 Nil 599} 600 601pub fn complete_command_after_assign_test() { 602 let #(matches, kind) = sys.complete_word("let x = ", "ran") 603 let assert True = kind == "command" 604 let assert True = list_contains(matches, "range") 605 Nil 606} 607 608pub fn complete_path_for_args_test() { 609 // After a command name, Tab completes files not commands 610 let #(_matches, kind) = sys.complete_word("echo ", "ech") 611 let assert True = kind == "path" 612 Nil 613} 614 615pub fn complete_path_like_command_test() { 616 // Path-shaped command words stay on filename completion 617 let #(_matches, kind) = sys.complete_word("", "./ec") 618 let assert True = kind == "path" 619 let #(_matches2, kind2) = sys.complete_word("", "/us") 620 let assert True = kind2 == "path" 621 Nil 622} 623 624pub fn complete_path_executable_test() { 625 // PATH + builtins for a non-empty prefix 626 let #(matches, kind) = sys.complete_word("", "s") 627 let assert True = kind == "command" 628 // At least builtins starting with s (select, skip, sort-by, …) 629 let assert True = list_contains(matches, "select") 630 Nil 631} 632 633fn list_contains(items: List(String), needle: String) -> Bool { 634 case items { 635 [] -> False 636 [x, ..] if x == needle -> True 637 [_, ..rest] -> list_contains(rest, needle) 638 } 639}